1 import type { ReactNode } from 'react';
2 import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useState } from 'react';
4 import type { APP_NAMES } from '@proton/shared/lib/constants';
5 import { APPS } from '@proton/shared/lib/constants';
7 canGetInboxDesktopInfo,
9 hasInboxDesktopFeature,
10 invokeInboxDesktopIPC,
11 } from '@proton/shared/lib/desktop/ipcHelpers';
12 import { clearBit, hasBit, setBit } from '@proton/shared/lib/helpers/bitset';
13 import { getCookie, setCookie } from '@proton/shared/lib/helpers/cookies';
14 import { isElectronMail, isElectronOnSupportedApps } from '@proton/shared/lib/helpers/desktop';
15 import { updateElectronThemeModeClassnames } from '@proton/shared/lib/helpers/initElectronClassnames';
16 import isDeepEqual from '@proton/shared/lib/helpers/isDeepEqual';
17 import createListeners from '@proton/shared/lib/helpers/listeners';
18 import { getSecondLevelDomain } from '@proton/shared/lib/helpers/url';
19 import type { ThemeInformation, ThemeSetting } from '@proton/shared/lib/themes/themes';
24 PROTON_DEFAULT_THEME_INFORMATION,
25 PROTON_DEFAULT_THEME_SETTINGS,
29 ThemeFontFaceSettingMap,
31 ThemeFontSizeSettingMap,
35 getDefaultThemeSetting,
36 getParsedThemeSetting,
37 getProminentHeaderThemes,
39 serializeThemeSetting,
40 } from '@proton/shared/lib/themes/themes';
41 import noop from '@proton/utils/noop';
43 import { classNames, styles } from './properties';
45 interface ThemeContextInterface {
46 setTheme: (theme: ThemeTypes, mode?: ThemeModeSetting) => void;
47 setThemeSetting: (theme?: ThemeSetting) => void;
48 setAutoTheme: (enabled: boolean) => void;
49 setFontSize: (fontSize: ThemeFontSizeSetting) => void;
50 setFontFace: (fontFace: ThemeFontFaceSetting) => void;
51 setFeature: (featureBit: ThemeFeatureSetting, toggle: boolean) => void;
52 settings: ThemeSetting;
53 information: ThemeInformation;
54 addListener: (cb: (data: ThemeSetting) => void) => () => void;
57 export const ThemeContext = createContext<ThemeContextInterface>({
59 setThemeSetting: noop,
64 settings: PROTON_DEFAULT_THEME_SETTINGS,
65 information: PROTON_DEFAULT_THEME_INFORMATION,
66 addListener: () => noop,
75 export const useTheme = () => {
76 return useContext(ThemeContext);
79 export const getThemeStyle = (themeType: ThemeTypes = PROTON_DEFAULT_THEME) => {
80 return PROTON_THEMES_MAP[themeType]?.theme || PROTON_THEMES_MAP[PROTON_DEFAULT_THEME].theme;
83 const THEME_COOKIE_NAME = 'Theme';
85 const storedTheme = getCookie(THEME_COOKIE_NAME);
87 export const THEME_ID = 'theme-root';
89 const matchMediaScheme = window.matchMedia('(prefers-color-scheme: dark)');
90 const matchMediaMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
92 const syncClassNameToEl = (el: HTMLElement | null, name: string, toggle: boolean) => {
97 el.classList.add(name);
99 el.classList.remove(name);
103 const syncStyleToEl = (el: HTMLElement | null, property: string, value: string | undefined) => {
107 el.style.removeProperty(property);
108 if (value === undefined) {
111 el.style.setProperty(property, value);
114 const getColorScheme = (matches: boolean): ColorScheme => {
115 if (canGetInboxDesktopInfo && hasInboxDesktopFeature('FullTheme')) {
116 return getInboxDesktopInfo('colorScheme');
119 return matches ? ColorScheme.Dark : ColorScheme.Light;
122 const getMotionMode = (matches: boolean): MotionModeSetting => {
123 return matches ? MotionModeSetting.Reduce : MotionModeSetting.No_preference;
126 const listeners = createListeners<[ThemeSetting]>();
128 const darkThemes = getDarkThemes();
130 const prominentHeaderThemes = getProminentHeaderThemes();
132 const ThemeProvider = ({ children, appName }: Props) => {
133 const [themeSetting, setThemeSettingDefault] = useState(() => {
134 return getParsedThemeSetting(storedTheme);
137 const constrainedThemeSettings: ThemeSetting = useMemo(() => {
138 // We want to protonwallet to inherit from all theme settings except styles
139 if (appName === APPS.PROTONWALLET) {
142 LightTheme: ThemeTypes.WalletLight,
143 // TOOD: move to wallet dark once theme is ready
144 DarkTheme: ThemeTypes.WalletLight,
151 const setThemeSetting = useCallback((theme: ThemeSetting = getDefaultThemeSetting()) => {
152 setThemeSettingDefault((oldTheme: ThemeSetting) => {
153 if (isDeepEqual(theme, oldTheme)) {
159 const [colorScheme, setColorScheme] = useState<ColorScheme>(() => {
160 return getColorScheme(matchMediaScheme.matches);
162 const [motionMode, setMotionMode] = useState<MotionModeSetting>(() => {
163 return getMotionMode(matchMediaMotion.matches);
166 useLayoutEffect(() => {
167 setColorScheme(getColorScheme(matchMediaScheme.matches));
168 const listener = (e: MediaQueryListEvent) => {
169 setColorScheme(getColorScheme(e.matches));
171 // Safari <14 does not support addEventListener on match media queries
172 matchMediaScheme.addEventListener?.('change', listener);
174 matchMediaScheme.removeEventListener?.('change', listener);
178 useLayoutEffect(() => {
179 setMotionMode(getMotionMode(matchMediaMotion.matches));
180 const listener = (e: MediaQueryListEvent) => {
181 setMotionMode(getMotionMode(e.matches));
183 // Safari <14 does not support addEventListener on match media queries
184 matchMediaMotion.addEventListener?.('change', listener);
186 matchMediaMotion.removeEventListener?.('change', listener);
190 const syncThemeSettingValue = (theme: ThemeSetting) => {
191 listeners.notify(theme);
192 setThemeSetting(theme);
195 const setTheme = (themeType: ThemeTypes, mode?: ThemeModeSetting) => {
197 syncThemeSettingValue({
199 Mode: ThemeModeSetting.Auto,
200 [mode === ThemeModeSetting.Dark ? 'DarkTheme' : 'LightTheme']: themeType,
205 if (darkThemes.includes(themeType)) {
206 syncThemeSettingValue({ ...themeSetting, Mode: ThemeModeSetting.Dark, DarkTheme: themeType });
208 syncThemeSettingValue({ ...themeSetting, Mode: ThemeModeSetting.Light, LightTheme: themeType });
212 const setAutoTheme = (enabled: boolean) => {
214 syncThemeSettingValue({ ...themeSetting, Mode: ThemeModeSetting.Auto });
216 syncThemeSettingValue({
218 Mode: colorScheme === ColorScheme.Light ? ThemeModeSetting.Light : ThemeModeSetting.Dark,
223 const setFontSize = (fontSize: ThemeFontSizeSetting) => {
224 syncThemeSettingValue({ ...themeSetting, FontSize: fontSize });
227 const setFontFace = (fontFace: ThemeFontFaceSetting) => {
228 syncThemeSettingValue({ ...themeSetting, FontFace: fontFace });
231 const setFeature = (featureBit: ThemeFeatureSetting, toggle: boolean) => {
232 syncThemeSettingValue({
234 Features: toggle ? setBit(themeSetting.Features, featureBit) : clearBit(themeSetting.Features, featureBit),
238 const theme = getThemeType(constrainedThemeSettings, colorScheme);
240 const style = getThemeStyle(theme);
242 const information: ThemeInformation = {
244 dark: darkThemes.includes(theme),
245 prominentHeader: prominentHeaderThemes.includes(theme),
246 default: PROTON_DEFAULT_THEME === theme,
248 label: PROTON_THEMES_MAP[theme]?.label || '',
252 scrollbars: hasBit(constrainedThemeSettings.Features, ThemeFeatureSetting.SCROLLBARS_OFF),
253 animations: hasBit(constrainedThemeSettings.Features, ThemeFeatureSetting.ANIMATIONS_OFF),
257 useLayoutEffect(() => {
258 const htmlEl = document.querySelector('html');
260 const defaultValue = ThemeFontSizeSettingMap[ThemeFontSizeSetting.DEFAULT].value;
261 const actualValue = ThemeFontSizeSettingMap[constrainedThemeSettings.FontSize]?.value;
263 const value = !actualValue || defaultValue === actualValue ? undefined : `${actualValue}`;
265 syncStyleToEl(htmlEl, styles.fontSize, value);
266 }, [constrainedThemeSettings.FontSize]);
268 useLayoutEffect(() => {
269 const htmlEl = document.querySelector('html');
271 const defaultValue = ThemeFontFaceSettingMap[ThemeFontFaceSetting.DEFAULT].value;
272 const actualValue = ThemeFontFaceSettingMap[constrainedThemeSettings.FontFace]?.value;
274 const value = !actualValue || defaultValue === actualValue ? undefined : `${actualValue}`;
276 syncStyleToEl(htmlEl, styles.fontFamily, value);
277 }, [constrainedThemeSettings.FontFace]);
279 useLayoutEffect(() => {
280 const htmlEl = document.querySelector('html');
282 syncClassNameToEl(htmlEl, classNames.scrollbars, information.features.scrollbars);
283 syncClassNameToEl(htmlEl, classNames.animations, information.features.animations);
284 }, [information.features.animations, information.features.scrollbars]);
287 const syncToMeta = () => {
288 const themeMeta = document.querySelector("meta[name='theme-color']");
289 const themeColor = PROTON_THEMES_MAP[theme].themeColorMeta;
291 if (themeMeta && themeColor) {
292 themeMeta.setAttribute('content', themeColor);
306 const syncToCookie = () => {
307 const cookieValue = serializeThemeSetting(themeSetting);
308 // Note: We might set `undefined` which will clear the cookie
310 cookieName: THEME_COOKIE_NAME,
312 cookieDomain: getSecondLevelDomain(window.location.hostname),
314 expirationDate: 'max',
322 if (appName && isElectronOnSupportedApps(appName) && hasInboxDesktopFeature('ThemeSelection')) {
323 invokeInboxDesktopIPC({ type: 'setTheme', payload: themeSetting });
328 if (isElectronMail) {
329 updateElectronThemeModeClassnames(themeSetting);
331 }, [colorScheme, themeSetting]);
334 <ThemeContext.Provider
336 settings: themeSetting,
344 addListener: listeners.subscribe,
347 <style id={THEME_ID}>{style}</style>
349 </ThemeContext.Provider>
353 export default ThemeProvider;