diff --git a/app/assets/javascripts/components/Dropdown.tsx b/app/assets/javascripts/components/Dropdown.tsx index 17739b175..b43a63804 100644 --- a/app/assets/javascripts/components/Dropdown.tsx +++ b/app/assets/javascripts/components/Dropdown.tsx @@ -9,21 +9,22 @@ import { import VisuallyHidden from '@reach/visually-hidden'; import { FunctionComponent } from 'preact'; import { IconType, Icon } from './Icon'; -import { useEffect, useState } from 'preact/hooks'; export type DropdownItem = { icon?: IconType; iconClassName?: string; label: string; value: string; + disabled?: boolean; }; type DropdownProps = { id: string; label: string; items: DropdownItem[]; - defaultValue: string; - onChange: (value: string) => void; + value: string; + onChange: (value: string, item: DropdownItem) => void; + disabled?: boolean; }; type ListboxButtonProps = DropdownItem & { @@ -59,20 +60,18 @@ export const Dropdown: FunctionComponent = ({ id, label, items, - defaultValue, + value, onChange, + disabled, }) => { - const [value, setValue] = useState(defaultValue); - - useEffect(() => { - setValue(defaultValue); - }, [defaultValue]); - const labelId = `${id}-label`; const handleChange = (value: string) => { - setValue(value); - onChange(value); + const selectedItem = items.find( + (item) => item.value === value + ) as DropdownItem; + + onChange(value, selectedItem); }; return ( @@ -82,6 +81,7 @@ export const Dropdown: FunctionComponent = ({ value={value} onChange={handleChange} aria-labelledby={labelId} + disabled={disabled} > = ({ className="sn-dropdown-item" value={item.value} label={item.label} + disabled={item.disabled} > {item.icon ? (
diff --git a/app/assets/javascripts/components/QuickSettingsMenu/QuickSettingsMenu.tsx b/app/assets/javascripts/components/QuickSettingsMenu/QuickSettingsMenu.tsx index c24a67d1f..ff55a364a 100644 --- a/app/assets/javascripts/components/QuickSettingsMenu/QuickSettingsMenu.tsx +++ b/app/assets/javascripts/components/QuickSettingsMenu/QuickSettingsMenu.tsx @@ -49,6 +49,19 @@ const toggleFocusMode = (enabled: boolean) => { } }; +export const sortThemes = (a: SNTheme, b: SNTheme) => { + const aIsLayerable = a.isLayerable(); + const bIsLayerable = b.isLayerable(); + + if (aIsLayerable && !bIsLayerable) { + return 1; + } else if (!aIsLayerable && bIsLayerable) { + return -1; + } else { + return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1; + } +}; + const QuickSettingsMenu: FunctionComponent = observer( ({ application, appState }) => { const { @@ -79,23 +92,7 @@ const QuickSettingsMenu: FunctionComponent = observer( const themes = application.getDisplayableItems( ContentType.Theme ) as SNTheme[]; - setThemes( - themes.sort((a, b) => { - const aIsLayerable = a.isLayerable(); - const bIsLayerable = b.isLayerable(); - - if (aIsLayerable && !bIsLayerable) { - return 1; - } else if (!aIsLayerable && bIsLayerable) { - return -1; - } else { - return a.name.toLowerCase() < - b.name.toLowerCase() - ? -1 - : 1; - } - }) - ); + setThemes(themes.sort(sortThemes)); setDefaultThemeOn( !themes.find((theme) => theme.active && !theme.isLayerable()) ); diff --git a/app/assets/javascripts/preferences/PreferencesMenu.ts b/app/assets/javascripts/preferences/PreferencesMenu.ts index e52e81f0f..47e29f05a 100644 --- a/app/assets/javascripts/preferences/PreferencesMenu.ts +++ b/app/assets/javascripts/preferences/PreferencesMenu.ts @@ -52,6 +52,7 @@ const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [ const READY_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [ { id: 'account', label: 'Account', icon: 'user' }, { id: 'general', label: 'General', icon: 'settings' }, + { id: 'appearance', label: 'Appearance', icon: 'themes' }, { id: 'security', label: 'Security', icon: 'security' }, { id: 'backups', label: 'Backups', icon: 'restore' }, { id: 'listed', label: 'Listed', icon: 'listed' }, diff --git a/app/assets/javascripts/preferences/PreferencesView.tsx b/app/assets/javascripts/preferences/PreferencesView.tsx index dd8173135..b53bc975e 100644 --- a/app/assets/javascripts/preferences/PreferencesView.tsx +++ b/app/assets/javascripts/preferences/PreferencesView.tsx @@ -18,6 +18,7 @@ import { AppState } from '@/ui_models/app_state'; import { useEffect, useMemo } from 'preact/hooks'; import { ExtensionPane } from './panes/ExtensionPane'; import { Backups } from '@/preferences/panes/Backups'; +import { Appearance } from './panes/Appearance'; interface PreferencesProps extends MfaProps { application: WebApplication; @@ -42,7 +43,7 @@ const PaneSelector: FunctionComponent< ); case 'appearance': - return null; + return ; case 'security': return ( = observer(({ application }) => { + const premiumModal = usePremiumModal(); + const isEntitledToMidnightTheme = + application.getFeatureStatus(FeatureIdentifier.MidnightTheme) === + FeatureStatus.Entitled; + + const [themeItems, setThemeItems] = useState([]); + const [autoLightTheme, setAutoLightTheme] = useState( + () => + application.getPreference( + PrefKey.AutoLightThemeIdentifier, + 'Default' + ) as string + ); + const [autoDarkTheme, setAutoDarkTheme] = useState( + () => + application.getPreference( + PrefKey.AutoDarkThemeIdentifier, + isEntitledToMidnightTheme ? FeatureIdentifier.MidnightTheme : 'Default' + ) as string + ); + const [useDeviceSettings, setUseDeviceSettings] = useState( + () => + application.getPreference(PrefKey.UseSystemColorScheme, false) as boolean + ); + + useEffect(() => { + const themesAsItems: DropdownItem[] = ( + application.getDisplayableItems(ContentType.Theme) as SNTheme[] + ) + .filter((theme) => !theme.isLayerable()) + .sort(sortThemes) + .map((theme) => { + return { + label: theme.name, + value: theme.identifier as string, + }; + }); + + Features.filter( + (feature) => + feature.content_type === ContentType.Theme && !feature.layerable + ).forEach((theme) => { + if ( + themesAsItems.findIndex((item) => item.value === theme.identifier) === + -1 + ) { + themesAsItems.push({ + label: theme.name as string, + value: theme.identifier, + icon: 'premium-feature', + }); + } + }); + + themesAsItems.unshift({ + label: 'Default', + value: 'Default', + }); + + setThemeItems(themesAsItems); + }, [application]); + + const toggleUseDeviceSettings = () => { + application.setPreference(PrefKey.UseSystemColorScheme, !useDeviceSettings); + if (!application.getPreference(PrefKey.AutoLightThemeIdentifier)) { + application.setPreference( + PrefKey.AutoLightThemeIdentifier, + autoLightTheme as FeatureIdentifier + ); + } + if (!application.getPreference(PrefKey.AutoDarkThemeIdentifier)) { + application.setPreference( + PrefKey.AutoDarkThemeIdentifier, + autoDarkTheme as FeatureIdentifier + ); + } + setUseDeviceSettings(!useDeviceSettings); + }; + + const changeAutoLightTheme = (value: string, item: DropdownItem) => { + if (item.icon === 'premium-feature') { + premiumModal.activate(`${item.label} theme`); + } else { + application.setPreference( + PrefKey.AutoLightThemeIdentifier, + value as FeatureIdentifier + ); + setAutoLightTheme(value); + } + }; + + const changeAutoDarkTheme = (value: string, item: DropdownItem) => { + if (item.icon === 'premium-feature') { + premiumModal.activate(`${item.label} theme`); + } else { + application.setPreference( + PrefKey.AutoDarkThemeIdentifier, + value as FeatureIdentifier + ); + setAutoDarkTheme(value); + } + }; + + return ( + + + + Themes +
+
+
+ Use system color scheme + + Automatically change active theme based on your system settings. + +
+ +
+ +
+ Automatic Light Theme + Theme to be used for system light mode: +
+ +
+
+ +
+ Automatic Dark Theme + Theme to be used for system dark mode: +
+ +
+
+
+
+
+
+ ); +}); + +export const Appearance: FunctionComponent = observer( + ({ application }) => ( + + + + ) +); diff --git a/app/assets/javascripts/preferences/panes/backups-segments/EmailBackups.tsx b/app/assets/javascripts/preferences/panes/backups-segments/EmailBackups.tsx index e9e6ffb48..2dd4a6cb3 100644 --- a/app/assets/javascripts/preferences/panes/backups-segments/EmailBackups.tsx +++ b/app/assets/javascripts/preferences/panes/backups-segments/EmailBackups.tsx @@ -157,7 +157,7 @@ export const EmailBackups = observer(({ application }: Props) => { id="def-editor-dropdown" label="Select email frequency" items={emailFrequencyOptions} - defaultValue={emailFrequency} + value={emailFrequency} onChange={(item) => { updateEmailFrequency(item as EmailBackupFrequency); }} diff --git a/app/assets/javascripts/preferences/panes/general-segments/Defaults.tsx b/app/assets/javascripts/preferences/panes/general-segments/Defaults.tsx index 2997622c9..5e550f969 100644 --- a/app/assets/javascripts/preferences/panes/general-segments/Defaults.tsx +++ b/app/assets/javascripts/preferences/panes/general-segments/Defaults.tsx @@ -84,7 +84,7 @@ const getDefaultEditor = (application: WebApplication) => { export const Defaults: FunctionComponent = ({ application }) => { const [editorItems, setEditorItems] = useState([]); - const [defaultEditorValue] = useState( + const [defaultEditorValue, setDefaultEditorValue] = useState( () => getDefaultEditor(application)?.package_info?.identifier || 'plain-editor' ); @@ -128,6 +128,7 @@ export const Defaults: FunctionComponent = ({ application }) => { }, [application]); const setDefaultEditor = (value: string) => { + setDefaultEditorValue(value as FeatureIdentifier); const editors = application.componentManager.componentsForArea( ComponentArea.Editor ); @@ -155,7 +156,7 @@ export const Defaults: FunctionComponent = ({ application }) => { id="def-editor-dropdown" label="Select the default editor" items={editorItems} - defaultValue={defaultEditorValue} + value={defaultEditorValue} onChange={setDefaultEditor} />
diff --git a/app/assets/javascripts/services/themeManager.ts b/app/assets/javascripts/services/themeManager.ts index 281627b86..2648397a0 100644 --- a/app/assets/javascripts/services/themeManager.ts +++ b/app/assets/javascripts/services/themeManager.ts @@ -10,6 +10,7 @@ import { UuidString, FeatureStatus, PayloadSource, + PrefKey, } from '@standardnotes/snjs'; const CACHED_THEMES_KEY = 'cachedThemes'; @@ -19,6 +20,53 @@ export class ThemeManager extends ApplicationService { private unregisterDesktop!: () => void; private unregisterStream!: () => void; + constructor(application: WebApplication) { + super(application); + this.colorSchemeEventHandler = this.colorSchemeEventHandler.bind(this); + } + + private colorSchemeEventHandler(event: MediaQueryListEvent) { + this.setThemeAsPerColorScheme(event.matches); + } + + private setThemeAsPerColorScheme(prefersDarkColorScheme: boolean) { + const useDeviceThemeSettings = this.application.getPreference( + PrefKey.UseSystemColorScheme, + false + ); + + if (useDeviceThemeSettings) { + const preference = prefersDarkColorScheme + ? PrefKey.AutoDarkThemeIdentifier + : PrefKey.AutoLightThemeIdentifier; + const themes = this.application.getDisplayableItems( + ContentType.Theme + ) as SNTheme[]; + + const enableDefaultTheme = () => { + const activeTheme = themes.find( + (theme) => theme.active && !theme.isLayerable() + ); + if (activeTheme) this.application.toggleTheme(activeTheme); + }; + + const themeIdentifier = this.application.getPreference( + preference, + 'Default' + ) as string; + if (themeIdentifier === 'Default') { + enableDefaultTheme(); + } else { + const theme = themes.find( + (theme) => theme.package_info.identifier === themeIdentifier + ); + if (theme && !theme.active) { + this.application.toggleTheme(theme); + } + } + } + } + async onAppEvent(event: ApplicationEvent) { super.onAppEvent(event); if (event === ApplicationEvent.SignedOut) { @@ -32,6 +80,15 @@ export class ThemeManager extends ApplicationService { await this.activateCachedThemes(); } else if (event === ApplicationEvent.FeaturesUpdated) { this.reloadThemeStatus(); + } else if (event === ApplicationEvent.Launched) { + window + .matchMedia('(prefers-color-scheme: dark)') + .addEventListener('change', this.colorSchemeEventHandler); + } else if (event === ApplicationEvent.PreferencesChanged) { + const prefersDarkColorScheme = window.matchMedia( + '(prefers-color-scheme: dark)' + ); + this.setThemeAsPerColorScheme(prefersDarkColorScheme.matches); } } @@ -46,6 +103,9 @@ export class ThemeManager extends ApplicationService { this.unregisterStream(); (this.unregisterDesktop as unknown) = undefined; (this.unregisterStream as unknown) = undefined; + window + .matchMedia('(prefers-color-scheme: dark)') + .removeEventListener('change', this.colorSchemeEventHandler); super.deinit(); } diff --git a/app/assets/javascripts/ui_models/app_state/app_state.ts b/app/assets/javascripts/ui_models/app_state/app_state.ts index 363a5ee27..41dd8d3dc 100644 --- a/app/assets/javascripts/ui_models/app_state/app_state.ts +++ b/app/assets/javascripts/ui_models/app_state/app_state.ts @@ -17,6 +17,7 @@ import { ComponentViewer, SNTag, NoteViewController, + SNTheme, } from '@standardnotes/snjs'; import pull from 'lodash/pull'; import { diff --git a/package.json b/package.json index 361089322..f33fa952c 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "@reach/tooltip": "^0.16.2", "@standardnotes/components": "1.4.0", "@standardnotes/features": "1.24.3", - "@standardnotes/snjs": "2.40.0", + "@standardnotes/snjs": "2.40.3", "@standardnotes/settings": "^1.9.0", "@standardnotes/sncrypto-web": "1.6.0", "mobx": "^6.3.5", diff --git a/yarn.lock b/yarn.lock index 90746d9e4..c7b73cc9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2615,10 +2615,10 @@ resolved "https://registry.yarnpkg.com/@standardnotes/components/-/components-1.4.0.tgz#0bb790965683b3fa56e3231b95cad9871e1db271" integrity sha512-8Zo2WV7Q+pWdmAf+rG3NCNtVM4N1P52T1sDijapz8xqtArT28wxWZkJ+qfBJ0lT5GmXxYZl8rY/tAkx4hQ5zSA== -"@standardnotes/domain-events@^2.16.8": - version "2.16.8" - resolved "https://registry.yarnpkg.com/@standardnotes/domain-events/-/domain-events-2.16.8.tgz#74e6a9879c9b4476d92a16253ae9d75cab4b8162" - integrity sha512-VHXEtXSNb01ensASq0d1iB4yMUdgBVlfeHp2LQNwK8fwtvdQOyPCfnOFdMaUjW+Y/nBFRz6swDtrLFLeknzlcw== +"@standardnotes/domain-events@^2.17.0": + version "2.17.0" + resolved "https://registry.yarnpkg.com/@standardnotes/domain-events/-/domain-events-2.17.0.tgz#e44f7927ccae9440d5ecc3d947ad0c47f4255cab" + integrity sha512-N7KCp4QplDdDVu8icnKQEYH2T0dHJJOMyanw4aSsQdK3FIqVnZE/oP3+kcNoPRwbX0m+HSiGkZYGRKQgOvNdiQ== dependencies: "@standardnotes/auth" "^3.15.2" @@ -2630,6 +2630,11 @@ "@standardnotes/auth" "^3.15.2" "@standardnotes/common" "^1.8.0" +"@standardnotes/settings@^1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@standardnotes/settings/-/settings-1.10.0.tgz#b46d4805c9a1cfb3fa3d9b3915daae9dd5661789" + integrity sha512-FUy4RDI7nFnbOGAaX5wMvBz+6Yto5tXGXDAi7bnWALZByTIlN8bkFZ2CNk2skjpzeOxBjqhCRi+GRCcuMqZ70A== + "@standardnotes/settings@^1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@standardnotes/settings/-/settings-1.9.0.tgz#0f01da5f6782363e4d77ee584b40f8614c555626" @@ -2649,16 +2654,16 @@ buffer "^6.0.3" libsodium-wrappers "^0.7.9" -"@standardnotes/snjs@2.40.0": - version "2.40.0" - resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.40.0.tgz#8b4e96bd11bdf4ea7f5c9d1cff869ad1a309245a" - integrity sha512-UBlMFp8Dj88snSKmi9vUVn97/EM5aidIWhZXQcFa1/OZHg7HVUbIPrLSVy8ftY6WNCPDXjNSE0cy2fozN9W2rQ== +"@standardnotes/snjs@2.40.3": + version "2.40.3" + resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.40.3.tgz#976dc1cce5f1633432331247bd5251bdde8d916d" + integrity sha512-esIAk2ymDTcABwnBoDA2n0tgZ5sOM0P/Wc4gCasjiR+9aVKoOhmY0lNF9u5E7ix+deuKs9DgE0fxKonSoYrHxA== dependencies: "@standardnotes/auth" "^3.15.2" "@standardnotes/common" "^1.8.0" - "@standardnotes/domain-events" "^2.16.8" + "@standardnotes/domain-events" "^2.17.0" "@standardnotes/features" "^1.24.3" - "@standardnotes/settings" "^1.9.0" + "@standardnotes/settings" "^1.10.0" "@standardnotes/sncrypto-common" "^1.6.0" "@svgr/babel-plugin-add-jsx-attribute@^5.4.0":