Skip to content

Commit

Permalink
refactor: theme state (#4310)
Browse files Browse the repository at this point in the history
  • Loading branch information
Schwehn42 authored Jul 15, 2024
1 parent a5f690e commit 053b020
Show file tree
Hide file tree
Showing 9 changed files with 96 additions and 34 deletions.
34 changes: 12 additions & 22 deletions src/components/Html/Html.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Helmet>;
const HelmetWorkaround: FC<HelmetProps> = ({...rest}) => <Helmet {...rest} />;
const HelmetWorkaround = ({...rest}: HelmetProps) => <Helmet {...rest} />;

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 <HelmetWorkaround title={title} htmlAttributes={{lang, theme}} />;
return <HelmetWorkaround title={title} htmlAttributes={{lang, theme: autoTheme.toString()}} />;
};
18 changes: 9 additions & 9 deletions src/components/SettingsDialog/Components/ThemeSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
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";
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 (
<div className="appearance-settings__theme-container">
Expand Down
1 change: 1 addition & 0 deletions src/constants/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
9 changes: 9 additions & 0 deletions src/store/action/view.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -60,6 +68,7 @@ export type ViewReduxAction =
| ReturnType<typeof ViewActionFactory.initApplication>
| ReturnType<typeof ViewActionFactory.setModerating>
| ReturnType<typeof ViewActionFactory.setLanguage>
| ReturnType<typeof ViewActionFactory.setTheme>
| ReturnType<typeof ViewActionFactory.setRoute>
| ReturnType<typeof ViewActionFactory.setServerInfo>
| ReturnType<typeof ViewActionFactory.setHotkeyState>
Expand Down
8 changes: 7 additions & 1 deletion src/store/middleware/view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, ApplicationState>, dispatch: Dispatch, action: ReduxAction) => {
Expand All @@ -26,6 +26,12 @@ export const passViewMiddleware = (stateAPI: MiddlewareAPI<Dispatch, Application
});
}

if (action.type === Action.SetTheme) {
if (typeof window !== undefined) {
saveToStorage(THEME_STORAGE_KEY, action.theme);
}
}

if (action.type === Action.EnableHotkeyNotifications) {
if (typeof window !== undefined) {
saveToStorage(HOTKEY_NOTIFICATIONS_ENABLE_STORAGE_KEY, JSON.stringify(true));
Expand Down
12 changes: 10 additions & 2 deletions src/store/reducer/view.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {ViewState} from "types/view";
import {Theme, ViewState} from "types/view";
import {Action, ReduxAction} from "store/action";
import {getFromStorage} 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";

const INITIAL_VIEW_STATE: ViewState = {
moderating: false,
Expand All @@ -12,6 +12,7 @@ const INITIAL_VIEW_STATE: ViewState = {
noteFocused: false,
hotkeyNotificationsEnabled: typeof window !== "undefined" && getFromStorage(HOTKEY_NOTIFICATIONS_ENABLE_STORAGE_KEY) !== "false",
showBoardReactions: typeof window !== "undefined" && getFromStorage(BOARD_REACTIONS_ENABLE_STORAGE_KEY) !== "false",
theme: ((typeof window !== "undefined" && getFromStorage(THEME_STORAGE_KEY)) as Theme) ?? "auto",
};

// eslint-disable-next-line @typescript-eslint/default-param-last
Expand All @@ -38,6 +39,13 @@ export const viewReducer = (state: ViewState = INITIAL_VIEW_STATE, action: Redux
};
}

case Action.SetTheme: {
return {
...state,
theme: action.theme,
};
}

case Action.SetServerInfo: {
return {
...state,
Expand Down
9 changes: 9 additions & 0 deletions src/types/view.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
// the theme that's stored in the state and local storage
export type Theme = "auto" | "light" | "dark";

// the theme that's automatically set as an attribute and used by stylesheets.
// if the initial theme is auto, this will be set to either light or dark depending on the system setting
export type AutoTheme = Omit<Theme, "auto">;

export interface View {
readonly moderating: boolean;

Expand All @@ -13,6 +20,8 @@ export interface View {

readonly language?: string;

readonly theme: Theme;

readonly route?: string;

readonly noteFocused: boolean;
Expand Down
38 changes: 38 additions & 0 deletions src/utils/hooks/useAutoTheme.ts
Original file line number Diff line number Diff line change
@@ -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<AutoTheme>(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;
};
1 change: 1 addition & 0 deletions src/utils/test/getTestApplicationState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export default (overwrite?: Partial<ApplicationState>): ApplicationState => ({
],
},
view: {
theme: "auto",
hotkeyNotificationsEnabled: true,
moderating: false,
serverTimeOffset: 0,
Expand Down

0 comments on commit 053b020

Please sign in to comment.