Cleanup - unused files / unused exports / duplicate exports
[ProtonMail-WebClient.git] / packages / components / containers / themes / ThemeProvider.tsx
blob3976016e4af70d6c305e19026eacec2748162783
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';
6 import {
7     canGetInboxDesktopInfo,
8     getInboxDesktopInfo,
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';
20 import {
21     ColorScheme,
22     MotionModeSetting,
23     PROTON_DEFAULT_THEME,
24     PROTON_DEFAULT_THEME_INFORMATION,
25     PROTON_DEFAULT_THEME_SETTINGS,
26     PROTON_THEMES_MAP,
27     ThemeFeatureSetting,
28     ThemeFontFaceSetting,
29     ThemeFontFaceSettingMap,
30     ThemeFontSizeSetting,
31     ThemeFontSizeSettingMap,
32     ThemeModeSetting,
33     ThemeTypes,
34     getDarkThemes,
35     getDefaultThemeSetting,
36     getParsedThemeSetting,
37     getProminentHeaderThemes,
38     getThemeType,
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>({
58     setTheme: noop,
59     setThemeSetting: noop,
60     setAutoTheme: noop,
61     setFontSize: noop,
62     setFontFace: noop,
63     setFeature: noop,
64     settings: PROTON_DEFAULT_THEME_SETTINGS,
65     information: PROTON_DEFAULT_THEME_INFORMATION,
66     addListener: () => noop,
67 });
69 interface Props {
70     appName: APP_NAMES;
71     children: ReactNode;
72     initial?: ThemeTypes;
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) => {
93     if (!el) {
94         return;
95     }
96     if (toggle) {
97         el.classList.add(name);
98     } else {
99         el.classList.remove(name);
100     }
103 const syncStyleToEl = (el: HTMLElement | null, property: string, value: string | undefined) => {
104     if (!el) {
105         return;
106     }
107     el.style.removeProperty(property);
108     if (value === undefined) {
109         return;
110     }
111     el.style.setProperty(property, value);
114 const getColorScheme = (matches: boolean): ColorScheme => {
115     if (canGetInboxDesktopInfo && hasInboxDesktopFeature('FullTheme')) {
116         return getInboxDesktopInfo('colorScheme');
117     }
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);
135     });
137     const constrainedThemeSettings: ThemeSetting = useMemo(() => {
138         // We want to protonwallet to inherit from all theme settings except styles
139         if (appName === APPS.PROTONWALLET) {
140             return {
141                 ...themeSetting,
142                 LightTheme: ThemeTypes.WalletLight,
143                 // TOOD: move to wallet dark once theme is ready
144                 DarkTheme: ThemeTypes.WalletLight,
145             };
146         }
148         return themeSetting;
149     }, [themeSetting]);
151     const setThemeSetting = useCallback((theme: ThemeSetting = getDefaultThemeSetting()) => {
152         setThemeSettingDefault((oldTheme: ThemeSetting) => {
153             if (isDeepEqual(theme, oldTheme)) {
154                 return oldTheme;
155             }
156             return theme;
157         });
158     }, []);
159     const [colorScheme, setColorScheme] = useState<ColorScheme>(() => {
160         return getColorScheme(matchMediaScheme.matches);
161     });
162     const [motionMode, setMotionMode] = useState<MotionModeSetting>(() => {
163         return getMotionMode(matchMediaMotion.matches);
164     });
166     useLayoutEffect(() => {
167         setColorScheme(getColorScheme(matchMediaScheme.matches));
168         const listener = (e: MediaQueryListEvent) => {
169             setColorScheme(getColorScheme(e.matches));
170         };
171         // Safari <14 does not support addEventListener on match media queries
172         matchMediaScheme.addEventListener?.('change', listener);
173         return () => {
174             matchMediaScheme.removeEventListener?.('change', listener);
175         };
176     }, []);
178     useLayoutEffect(() => {
179         setMotionMode(getMotionMode(matchMediaMotion.matches));
180         const listener = (e: MediaQueryListEvent) => {
181             setMotionMode(getMotionMode(e.matches));
182         };
183         // Safari <14 does not support addEventListener on match media queries
184         matchMediaMotion.addEventListener?.('change', listener);
185         return () => {
186             matchMediaMotion.removeEventListener?.('change', listener);
187         };
188     }, []);
190     const syncThemeSettingValue = (theme: ThemeSetting) => {
191         listeners.notify(theme);
192         setThemeSetting(theme);
193     };
195     const setTheme = (themeType: ThemeTypes, mode?: ThemeModeSetting) => {
196         if (mode) {
197             syncThemeSettingValue({
198                 ...themeSetting,
199                 Mode: ThemeModeSetting.Auto,
200                 [mode === ThemeModeSetting.Dark ? 'DarkTheme' : 'LightTheme']: themeType,
201             });
202             return;
203         }
205         if (darkThemes.includes(themeType)) {
206             syncThemeSettingValue({ ...themeSetting, Mode: ThemeModeSetting.Dark, DarkTheme: themeType });
207         } else {
208             syncThemeSettingValue({ ...themeSetting, Mode: ThemeModeSetting.Light, LightTheme: themeType });
209         }
210     };
212     const setAutoTheme = (enabled: boolean) => {
213         if (enabled) {
214             syncThemeSettingValue({ ...themeSetting, Mode: ThemeModeSetting.Auto });
215         } else {
216             syncThemeSettingValue({
217                 ...themeSetting,
218                 Mode: colorScheme === ColorScheme.Light ? ThemeModeSetting.Light : ThemeModeSetting.Dark,
219             });
220         }
221     };
223     const setFontSize = (fontSize: ThemeFontSizeSetting) => {
224         syncThemeSettingValue({ ...themeSetting, FontSize: fontSize });
225     };
227     const setFontFace = (fontFace: ThemeFontFaceSetting) => {
228         syncThemeSettingValue({ ...themeSetting, FontFace: fontFace });
229     };
231     const setFeature = (featureBit: ThemeFeatureSetting, toggle: boolean) => {
232         syncThemeSettingValue({
233             ...themeSetting,
234             Features: toggle ? setBit(themeSetting.Features, featureBit) : clearBit(themeSetting.Features, featureBit),
235         });
236     };
238     const theme = getThemeType(constrainedThemeSettings, colorScheme);
240     const style = getThemeStyle(theme);
242     const information: ThemeInformation = {
243         theme,
244         dark: darkThemes.includes(theme),
245         prominentHeader: prominentHeaderThemes.includes(theme),
246         default: PROTON_DEFAULT_THEME === theme,
247         style,
248         label: PROTON_THEMES_MAP[theme]?.label || '',
249         colorScheme,
250         motionMode,
251         features: {
252             scrollbars: hasBit(constrainedThemeSettings.Features, ThemeFeatureSetting.SCROLLBARS_OFF),
253             animations: hasBit(constrainedThemeSettings.Features, ThemeFeatureSetting.ANIMATIONS_OFF),
254         },
255     };
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]);
286     useEffect(() => {
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);
293             }
294         };
296         syncToMeta();
297     }, [theme]);
299     useEffect(() => {
300         return () => {
301             listeners.clear();
302         };
303     }, []);
305     useEffect(() => {
306         const syncToCookie = () => {
307             const cookieValue = serializeThemeSetting(themeSetting);
308             // Note: We might set `undefined` which will clear the cookie
309             setCookie({
310                 cookieName: THEME_COOKIE_NAME,
311                 cookieValue,
312                 cookieDomain: getSecondLevelDomain(window.location.hostname),
313                 path: '/',
314                 expirationDate: 'max',
315             });
316         };
318         syncToCookie();
319     }, [themeSetting]);
321     useEffect(() => {
322         if (appName && isElectronOnSupportedApps(appName) && hasInboxDesktopFeature('ThemeSelection')) {
323             invokeInboxDesktopIPC({ type: 'setTheme', payload: themeSetting });
324         }
325     }, [themeSetting]);
327     useEffect(() => {
328         if (isElectronMail) {
329             updateElectronThemeModeClassnames(themeSetting);
330         }
331     }, [colorScheme, themeSetting]);
333     return (
334         <ThemeContext.Provider
335             value={{
336                 settings: themeSetting,
337                 setTheme,
338                 setThemeSetting,
339                 setAutoTheme,
340                 setFontSize,
341                 setFontFace,
342                 setFeature,
343                 information,
344                 addListener: listeners.subscribe,
345             }}
346         >
347             <style id={THEME_ID}>{style}</style>
348             {children}
349         </ThemeContext.Provider>
350     );
353 export default ThemeProvider;