From 73e3aa731903bb8ce794cc841efec408c7d3e253 Mon Sep 17 00:00:00 2001 From: Maxim Givanyan Date: Fri, 19 Jan 2024 16:54:53 +0300 Subject: [PATCH] feat(core/system-theme-extractor): refactor theme-manager to work asynchronously --- src/components/super/i-block/mods/index.ts | 10 +- .../super/i-static-page/i-static-page.ts | 7 +- .../i-static-page/modules/theme/const.ts | 28 +++ .../i-static-page/modules/theme/factory.ts | 13 +- .../i-static-page/modules/theme/interface.ts | 22 +++ .../modules/theme/theme-manager.ts | 159 +++++++++++------- 6 files changed, 175 insertions(+), 64 deletions(-) create mode 100644 src/components/super/i-static-page/modules/theme/const.ts create mode 100644 src/components/super/i-static-page/modules/theme/interface.ts diff --git a/src/components/super/i-block/mods/index.ts b/src/components/super/i-block/mods/index.ts index 9676b93f96..9d4b229c3a 100644 --- a/src/components/super/i-block/mods/index.ts +++ b/src/components/super/i-block/mods/index.ts @@ -221,8 +221,14 @@ export default abstract class iBlockMods extends iBlockEvent { * Initializes the theme modifier and attaches a listener to watch changing of the theme */ @hook('created') - protected initThemeModListener(): void { - void this.setMod('theme', this.r.theme?.current); + protected async initThemeModListener(): Promise { + if (this.r.theme == null) { + return; + } + + const cur = await this.r.theme.getTheme(); + + void this.setMod('theme', cur); this.rootEmitter.on( 'onTheme:change', diff --git a/src/components/super/i-static-page/i-static-page.ts b/src/components/super/i-static-page/i-static-page.ts index 66ae9fc239..f35b38c790 100644 --- a/src/components/super/i-static-page/i-static-page.ts +++ b/src/components/super/i-static-page/i-static-page.ts @@ -96,7 +96,12 @@ export default abstract class iStaticPage extends iPage { /** * A module to manage app themes from the Design System */ - @system(themeManagerFactory) + @system((o) => themeManagerFactory( + o, + import('core/kv-storage/engines/cookie').then((cookie) => cookie.syncLocalStorage), + import('core/system-theme-extractor/engines/web').then((engine) => engine.webEngineFactory(o)) + )) + readonly theme: CanUndef; /** diff --git a/src/components/super/i-static-page/modules/theme/const.ts b/src/components/super/i-static-page/modules/theme/const.ts new file mode 100644 index 0000000000..91e393a0ba --- /dev/null +++ b/src/components/super/i-static-page/modules/theme/const.ts @@ -0,0 +1,28 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/** + * Indicates whether the detection of the user's preferred color scheme is enabled. + * Defaults to `false` if not specified in `DETECT_USER_PREFERENCES`. + */ +export const prefersColorSchemeEnabled = + Object.get(DETECT_USER_PREFERENCES, 'prefersColorScheme.enabled') ?? false; + +/** + * The name associated with the dark color scheme. + * Defaults to 'dark' if not specified in `DETECT_USER_PREFERENCES`. + */ +export const darkThemeName = + Object.get(DETECT_USER_PREFERENCES, 'prefersColorScheme.aliases.dark') ?? 'dark'; + +/** + * The name associated with the light color scheme. + * Defaults to 'light' if not specified in `DETECT_USER_PREFERENCES`. + */ +export const lightThemeName = + Object.get(DETECT_USER_PREFERENCES, 'prefersColorScheme.aliases.light') ?? 'light'; diff --git a/src/components/super/i-static-page/modules/theme/factory.ts b/src/components/super/i-static-page/modules/theme/factory.ts index b47a58cb07..72065646a7 100644 --- a/src/components/super/i-static-page/modules/theme/factory.ts +++ b/src/components/super/i-static-page/modules/theme/factory.ts @@ -8,11 +8,20 @@ import type iBlock from 'components/super/i-block/i-block'; import ThemeManager from 'components/super/i-static-page/modules/theme/theme-manager'; +import type { StorageEngine } from 'core/kv-storage'; +import type { SystemThemeExtractor } from 'core/system-theme-extractor'; /** * Returns an instance of the class for managing interface themes, if that functionality is available + * * @param component + * @param store + * @param systemTheme */ -export default function themeManagerFactory(component: iBlock): CanNull { - return Object.isString(THEME) ? new ThemeManager(component) : null; +export default function themeManagerFactory( + component: iBlock, + store: CanPromise, + systemTheme: CanPromise +): CanNull { + return Object.isString(THEME) ? new ThemeManager(component, store, systemTheme) : null; } diff --git a/src/components/super/i-static-page/modules/theme/interface.ts b/src/components/super/i-static-page/modules/theme/interface.ts new file mode 100644 index 0000000000..65da58ddc2 --- /dev/null +++ b/src/components/super/i-static-page/modules/theme/interface.ts @@ -0,0 +1,22 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/** + * Represents a visual theme with its associated properties + */ +export interface Theme { + /** + * The value representing the visual theme + */ + value: string; + + /** + * Indicates whether the current theme value is derived from system settings + */ + isSystem: boolean; +} diff --git a/src/components/super/i-static-page/modules/theme/theme-manager.ts b/src/components/super/i-static-page/modules/theme/theme-manager.ts index 9e848d3527..4c5d279cc6 100644 --- a/src/components/super/i-static-page/modules/theme/theme-manager.ts +++ b/src/components/super/i-static-page/modules/theme/theme-manager.ts @@ -9,16 +9,23 @@ import symbolGenerator from 'core/symbol'; import { factory, SyncStorage, StorageEngine } from 'core/kv-storage'; -import * as cookieEngine from 'core/kv-storage/engines/cookie'; +import SyncPromise from 'core/promise/sync'; import type iBlock from 'components/super/i-block/i-block'; import type iStaticPage from 'components/super/i-static-page/i-static-page'; import Friend from 'components/friends/friend'; +import type { Theme } from 'components/super/i-static-page/modules/theme/interface'; +import type { SystemThemeExtractor } from 'core/system-theme-extractor'; + +import { prefersColorSchemeEnabled, darkThemeName, lightThemeName } from 'components/super/i-static-page/modules/theme/const'; const $$ = symbolGenerator(); +export * from 'components/super/i-static-page/modules/theme/interface'; +export * from 'components/super/i-static-page/modules/theme/const'; + export default class ThemeManager extends Friend { override readonly C!: iStaticPage; @@ -30,17 +37,27 @@ export default class ThemeManager extends Friend { /** * Current theme value */ - protected currentStore!: string; + protected currentStore!: Theme; + + /** + * A promise that resolves when the ThemeManager is initialized. + */ + protected readonly initPromise!: Promise; + + /** + * An API for obtaining and observing system appearance. + */ + protected systemThemeExtractor!: SystemThemeExtractor; /** * Initial theme value */ - protected readonly initialValue!: string; + protected initialValue!: Theme; /** * An API for persistent theme storage */ - protected readonly themeStorage!: SyncStorage; + protected themeStorage!: SyncStorage; /** * An attribute to set the theme value to the root element @@ -50,52 +67,56 @@ export default class ThemeManager extends Friend { /** * @param component * @param themeStorageEngine - engine for persistent theme storage + * @param systemThemeExtractor */ - constructor(component: iBlock, themeStorageEngine: StorageEngine = cookieEngine.syncLocalStorage) { + constructor( + component: iBlock, + themeStorageEngine: CanPromise, + systemThemeExtractor: CanPromise + ) { super(component); if (!Object.isString(THEME)) { throw new ReferenceError('A theme to initialize is not specified'); } - this.themeStorage = factory(themeStorageEngine); - this.availableThemes = new Set(AVAILABLE_THEMES ?? []); - - let theme = THEME; - - if (POST_PROCESS_THEME) { - const themeFromCookie = this.themeStorage.get('colorTheme'); - - if (themeFromCookie != null && this.availableThemes.has(themeFromCookie)) { - theme = themeFromCookie; - } - - } else if (Object.isDictionary(DETECT_USER_PREFERENCES)) { - const - prefersColorSchemeEnabled = Object.get(DETECT_USER_PREFERENCES, 'prefersColorScheme.enabled') ?? false, - darkTheme = Object.get(DETECT_USER_PREFERENCES, 'prefersColorScheme.aliases.dark') ?? 'dark', - lightTheme = Object.get(DETECT_USER_PREFERENCES, 'prefersColorScheme.aliases.light') ?? 'light'; - - if (prefersColorSchemeEnabled) { - const darkThemeMq = globalThis.matchMedia('(prefers-color-scheme: dark)'); - theme = darkThemeMq.matches ? darkTheme : lightTheme; - - this.initThemeListener(darkThemeMq, prefersColorSchemeEnabled, darkTheme, lightTheme); - } - } - - this.current = theme; - this.initialValue = theme; - if (!Object.isString(this.themeAttribute)) { throw new ReferenceError('An attribute name to set themes is not specified'); } + + this.availableThemes = new Set(AVAILABLE_THEMES ?? []); + + this.initPromise = this.async.promise( + Promise.all([themeStorageEngine, systemThemeExtractor]) + .then(async ([storageEngine, systemThemeExtractor]) => { + this.themeStorage = factory(storageEngine); + this.systemThemeExtractor = systemThemeExtractor; + + let theme = {value: THEME, isSystem: false}; + + if (POST_PROCESS_THEME) { + const themeFromStore = this.themeStorage.get('colorTheme'); + + if (themeFromStore != null) { + theme = themeFromStore; + } + } else if (prefersColorSchemeEnabled) { + theme.isSystem = true; + } + + this.initialValue = theme; + return this.updateTheme(theme); + }) + .then(() => this), + {label: $$.themeManagerInit} + ); } /** - * Current theme value + * Returns current theme */ - get current(): string { + async getTheme(): Promise { + await this.initPromise; return this.currentStore; } @@ -103,9 +124,48 @@ export default class ThemeManager extends Friend { * Sets a new value to the current theme * * @param value + * @param [isSystem] + */ + async setTheme(value: string, isSystem?: boolean): Promise; + async setTheme(value: Theme): Promise; + async setTheme(value: Theme | string, isSystem: boolean = false): Promise { + await this.initPromise; + + if (typeof value !== 'string') { + isSystem = value.isSystem; + value = value.value; + } + + return this.updateTheme({value, isSystem}); + } + + /** + * Sets a new value to the current theme + * + * @param theme * @emits `theme:change(value: string, oldValue: CanUndef)` */ - set current(value: string) { + protected async updateTheme(theme: Theme): Promise { + let + {isSystem, value} = theme; + + if (isSystem) { + value = await this.systemThemeExtractor.getSystemTheme(); + + this.systemThemeExtractor.initThemeChangeListener( + (value: string) => { + if (prefersColorSchemeEnabled) { + value = value === 'dark' ? darkThemeName : lightThemeName; + } + + void this.updateTheme({value, isSystem: true}); + } + ); + + } else { + this.systemThemeExtractor.terminateThemeChangeListener(); + } + if (!this.availableThemes.has(value)) { throw new ReferenceError(`A theme with the name "${value}" is not defined`); } @@ -114,33 +174,14 @@ export default class ThemeManager extends Friend { return; } - const oldValue = this.currentStore; + const oldValue = this.field.get('currentStore.value'); - this.currentStore = value; - this.themeStorage.set('colorTheme', value); + this.currentStore = {value, isSystem}; + this.themeStorage.set('colorTheme', this.currentStore); document.documentElement.setAttribute(this.themeAttribute, value); void this.component.lfc.execCbAtTheRightTime(() => { this.component.emit('theme:change', value, oldValue); }); } - - /** - * Initializes an event listener for changes in system appearance - * - * @param mq - * @param enabled - * @param darkTheme - * @param lightTheme - */ - protected initThemeListener(mq: MediaQueryList, enabled: boolean, darkTheme: string, lightTheme: string): void { - if (!enabled) { - return; - } - - // TODO: understand why cant we use `this.async.on(mq, 'change', ...)`; https://github.com/V4Fire/Core/issues/369 - mq.onchange = this.async.proxy((event: MediaQueryListEvent) => ( - this.current = event.matches ? darkTheme : lightTheme - ), {single: false, label: $$.themeChange}); - } }