diff --git a/src/components/Html/Html.tsx b/src/components/Html/Html.tsx index 789c3b88ee..4c71ca349f 100644 --- a/src/components/Html/Html.tsx +++ b/src/components/Html/Html.tsx @@ -1,32 +1,22 @@ -import {FC, useEffect, useState} from "react"; -import {Helmet} from "react-helmet"; +import {useEffect} from "react"; +import {Helmet, HelmetProps} from "react-helmet"; import {useAppSelector} from "store"; +import {useAutoTheme} from "utils/hooks/useAutoTheme"; -type HelmetProps = React.ComponentProps; -const HelmetWorkaround: FC = ({...rest}) => ; +const HelmetWorkaround = ({...rest}: HelmetProps) => ; -export const Html: FC = () => { +export const Html = () => { const lang = useAppSelector((state) => state.view.language); let title = useAppSelector((state) => state.board.data?.name); - const [theme, setTheme] = useState(localStorage.getItem("theme") ?? (!window.matchMedia || window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")); - if (title) title = `scrumlr.io - ${title}`; - useEffect(() => { - if (theme === "auto") { - const autoTheme = window.matchMedia("(prefers-color-scheme: dark)")?.matches ? "dark" : "light"; - setTheme(autoTheme); - } - }, [theme]); - - window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => { - const colorScheme = e.matches ? "dark" : "light"; + const theme = useAppSelector((state) => state.view.theme); + const autoTheme = useAutoTheme(theme); - if (!localStorage.getItem("theme") || localStorage.getItem("theme") === "auto") { - setTheme(colorScheme); - document.documentElement.setAttribute("theme", colorScheme); - } - }); + // set the theme as an attribute, which can then be used inside stylesheets, e.g. [theme="dark"] {...} + useEffect(() => { + document.documentElement.setAttribute("theme", autoTheme.toString()); + }, [autoTheme]); - return ; + return ; }; diff --git a/src/components/SettingsDialog/Components/ThemeSettings.tsx b/src/components/SettingsDialog/Components/ThemeSettings.tsx index ad356a1d94..b7e34efe6d 100644 --- a/src/components/SettingsDialog/Components/ThemeSettings.tsx +++ b/src/components/SettingsDialog/Components/ThemeSettings.tsx @@ -1,4 +1,7 @@ -import {useEffect, useState} from "react"; +import {useAppSelector} from "store"; +import {useDispatch} from "react-redux"; +import {Theme} from "types/view"; +import {Actions} from "store/action"; import {t} from "i18next"; import {SettingsDarkMode, SettingsLightMode, GeneralSettings} from "components/Icon"; import ThemePreviewDark from "assets/themes/theme-preview-dark.svg"; @@ -6,15 +9,12 @@ import ThemePreviewLight from "assets/themes/theme-preview-light.svg"; import "./ThemeSettings.scss"; export const ThemeSettings = () => { - const [theme, setTheme] = useState(localStorage.getItem("theme") ?? "auto"); - useEffect(() => { - if (theme === "auto") { - const autoTheme = window.matchMedia("(prefers-color-scheme: dark)")?.matches ? "dark" : "light"; - document.documentElement.setAttribute("theme", autoTheme); - } else document.documentElement.setAttribute("theme", theme!); + const dispatch = useDispatch(); + const theme = useAppSelector((state) => state.view.theme); - localStorage.setItem("theme", theme!); - }, [theme]); + const setTheme = (newTheme: Theme) => { + dispatch(Actions.setTheme(newTheme)); + }; return (
diff --git a/src/constants/storage.ts b/src/constants/storage.ts index 9138ae194f..9d1b44f35f 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -2,6 +2,7 @@ export const CLIENT_STORAGE_PREFIX = "scrumlr/"; export const APP_VERSION_STORAGE_KEY = `${CLIENT_STORAGE_PREFIX}app_version`; export const COOKIE_CONSENT_STORAGE_KEY = `${CLIENT_STORAGE_PREFIX}cookie_consent`; export const LOCALE_STORAGE_KEY = `${CLIENT_STORAGE_PREFIX}locale`; +export const THEME_STORAGE_KEY = `${CLIENT_STORAGE_PREFIX}theme`; export const CUSTOM_TIMER_STORAGE_KEY = `${CLIENT_STORAGE_PREFIX}custom_timer`; export const CUSTOM_NUMBER_OF_VOTES_STORAGE_KEY = `${CLIENT_STORAGE_PREFIX}custom_number_of_votes`; export const CUMULATIVE_VOTING_DEFAULT_STORAGE_KEY = `${CLIENT_STORAGE_PREFIX}cumulative_voting_default`; diff --git a/src/store/action/view.ts b/src/store/action/view.ts index 1898969555..fa59851e0e 100644 --- a/src/store/action/view.ts +++ b/src/store/action/view.ts @@ -1,7 +1,10 @@ +import {Theme} from "types/view"; + export const ViewAction = { InitApplication: "scrumlr.io/initApplication" as const, SetModerating: "scrumlr.io/setModerating" as const, SetLanguage: "scrumlr.io/setLanguage" as const, + SetTheme: "scrumlr.io/setTheme" as const, SetServerInfo: "scrumlr.io/setServerInfo" as const, SetRoute: "scrumlr.io/setRoute" as const, SetHotkeyState: "scrumlr.io/setHotkeyState" as const, @@ -25,6 +28,11 @@ export const ViewActionFactory = { language, }), + setTheme: (theme: Theme) => ({ + type: ViewAction.SetTheme, + theme, + }), + setServerInfo: (enabledAuthProvider: string[], serverTime: number, feedbackEnabled: boolean) => ({ type: ViewAction.SetServerInfo, enabledAuthProvider, @@ -60,6 +68,7 @@ export type ViewReduxAction = | ReturnType | ReturnType | ReturnType + | ReturnType | ReturnType | ReturnType | ReturnType diff --git a/src/store/middleware/view.tsx b/src/store/middleware/view.tsx index 35cf8f53f0..fa694cc889 100644 --- a/src/store/middleware/view.tsx +++ b/src/store/middleware/view.tsx @@ -5,7 +5,7 @@ import {API} from "api"; import i18n from "i18n"; import {Toast} from "utils/Toast"; import {saveToStorage} from "utils/storage"; -import {BOARD_REACTIONS_ENABLE_STORAGE_KEY, HOTKEY_NOTIFICATIONS_ENABLE_STORAGE_KEY} from "constants/storage"; +import {BOARD_REACTIONS_ENABLE_STORAGE_KEY, HOTKEY_NOTIFICATIONS_ENABLE_STORAGE_KEY, THEME_STORAGE_KEY} from "constants/storage"; import store from "../index"; export const passViewMiddleware = (stateAPI: MiddlewareAPI, dispatch: Dispatch, action: ReduxAction) => { @@ -26,6 +26,12 @@ export const passViewMiddleware = (stateAPI: MiddlewareAPI; + export interface View { readonly moderating: boolean; @@ -13,6 +20,8 @@ export interface View { readonly language?: string; + readonly theme: Theme; + readonly route?: string; readonly noteFocused: boolean; diff --git a/src/utils/hooks/useAutoTheme.ts b/src/utils/hooks/useAutoTheme.ts new file mode 100644 index 0000000000..89f9ef6e7d --- /dev/null +++ b/src/utils/hooks/useAutoTheme.ts @@ -0,0 +1,38 @@ +import {useState, useEffect, useCallback} from "react"; +import {Theme, AutoTheme} from "types/view"; + +/** this hook return the theme that should be used, regarding the current system preferences. */ +export const useAutoTheme = (theme: Theme): AutoTheme => { + const getInitialAutoTheme = useCallback((): AutoTheme => { + if (theme === "auto") { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + return theme; + }, [theme]); + + const [autoTheme, setAutoTheme] = useState(getInitialAutoTheme()); + + // update the theme. if it's set to auto, use the system preference, otherwise use the value. + useEffect(() => { + const handleColorSchemeChange = (e: MediaQueryListEvent) => { + const colorSchemePreference: AutoTheme = e.matches ? "dark" : "light"; + + if (theme === "auto") { + setAutoTheme(colorSchemePreference); + } + }; + + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + mediaQuery.addEventListener("change", handleColorSchemeChange); + + // update the theme based on the initial value and theme changes + setAutoTheme(getInitialAutoTheme()); + + return () => { + // cleanup + mediaQuery.removeEventListener("change", handleColorSchemeChange); + }; + }, [getInitialAutoTheme, theme]); + + return autoTheme; +}; diff --git a/src/utils/test/getTestApplicationState.ts b/src/utils/test/getTestApplicationState.ts index 3e9523a8a8..c597fa34d0 100644 --- a/src/utils/test/getTestApplicationState.ts +++ b/src/utils/test/getTestApplicationState.ts @@ -172,6 +172,7 @@ export default (overwrite?: Partial): ApplicationState => ({ ], }, view: { + theme: "auto", hotkeyNotificationsEnabled: true, moderating: false, serverTimeOffset: 0,