feat: Add "Appearance" pane to preferences (#816)

Co-authored-by: Mo <mo@standardnotes.com>
This commit is contained in:
Aman Harwara
2022-01-19 19:41:04 +05:30
committed by GitHub
parent c232a5e3c6
commit da1d4f75c8
11 changed files with 307 additions and 44 deletions

View File

@@ -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<DropdownProps> = ({
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<DropdownProps> = ({
value={value}
onChange={handleChange}
aria-labelledby={labelId}
disabled={disabled}
>
<ListboxButton
className="sn-dropdown-button"
@@ -106,6 +106,7 @@ export const Dropdown: FunctionComponent<DropdownProps> = ({
className="sn-dropdown-item"
value={item.value}
label={item.label}
disabled={item.disabled}
>
{item.icon ? (
<div className="flex mr-3">

View File

@@ -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<MenuProps> = observer(
({ application, appState }) => {
const {
@@ -79,23 +92,7 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = 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())
);

View File

@@ -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' },

View File

@@ -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<
<AccountPreferences application={application} appState={appState} />
);
case 'appearance':
return null;
return <Appearance application={application} />;
case 'security':
return (
<Security

View File

@@ -0,0 +1,196 @@
import { Dropdown, DropdownItem } from '@/components/Dropdown';
import { PremiumModalProvider, usePremiumModal } from '@/components/Premium';
import { sortThemes } from '@/components/QuickSettingsMenu/QuickSettingsMenu';
import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
import { Switch } from '@/components/Switch';
import { WebApplication } from '@/ui_models/application';
import { Features } from '@standardnotes/features';
import {
ContentType,
FeatureIdentifier,
FeatureStatus,
PrefKey,
SNTheme,
} from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import {
PreferencesGroup,
PreferencesPane,
PreferencesSegment,
Subtitle,
Title,
Text,
} from '../components';
type Props = {
application: WebApplication;
};
const AppearancePane: FunctionComponent<Props> = observer(({ application }) => {
const premiumModal = usePremiumModal();
const isEntitledToMidnightTheme =
application.getFeatureStatus(FeatureIdentifier.MidnightTheme) ===
FeatureStatus.Entitled;
const [themeItems, setThemeItems] = useState<DropdownItem[]>([]);
const [autoLightTheme, setAutoLightTheme] = useState<string>(
() =>
application.getPreference(
PrefKey.AutoLightThemeIdentifier,
'Default'
) as string
);
const [autoDarkTheme, setAutoDarkTheme] = useState<string>(
() =>
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 (
<PreferencesPane>
<PreferencesGroup>
<PreferencesSegment>
<Title>Themes</Title>
<div className="mt-2">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Use system color scheme</Subtitle>
<Text>
Automatically change active theme based on your system settings.
</Text>
</div>
<Switch
onChange={toggleUseDeviceSettings}
checked={useDeviceSettings}
/>
</div>
<HorizontalSeparator classes="mt-5 mb-3" />
<div>
<Subtitle>Automatic Light Theme</Subtitle>
<Text>Theme to be used for system light mode:</Text>
<div className="mt-2">
<Dropdown
id="auto-light-theme-dropdown"
label="Select the automatic light theme"
items={themeItems}
value={autoLightTheme}
onChange={changeAutoLightTheme}
disabled={!useDeviceSettings}
/>
</div>
</div>
<HorizontalSeparator classes="mt-5 mb-3" />
<div>
<Subtitle>Automatic Dark Theme</Subtitle>
<Text>Theme to be used for system dark mode:</Text>
<div className="mt-2">
<Dropdown
id="auto-dark-theme-dropdown"
label="Select the automatic dark theme"
items={themeItems}
value={autoDarkTheme}
onChange={changeAutoDarkTheme}
disabled={!useDeviceSettings}
/>
</div>
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
</PreferencesPane>
);
});
export const Appearance: FunctionComponent<Props> = observer(
({ application }) => (
<PremiumModalProvider state={application.getAppState().features}>
<AppearancePane application={application} />
</PremiumModalProvider>
)
);

View File

@@ -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);
}}

View File

@@ -84,7 +84,7 @@ const getDefaultEditor = (application: WebApplication) => {
export const Defaults: FunctionComponent<Props> = ({ application }) => {
const [editorItems, setEditorItems] = useState<DropdownItem[]>([]);
const [defaultEditorValue] = useState(
const [defaultEditorValue, setDefaultEditorValue] = useState(
() =>
getDefaultEditor(application)?.package_info?.identifier || 'plain-editor'
);
@@ -128,6 +128,7 @@ export const Defaults: FunctionComponent<Props> = ({ 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<Props> = ({ application }) => {
id="def-editor-dropdown"
label="Select the default editor"
items={editorItems}
defaultValue={defaultEditorValue}
value={defaultEditorValue}
onChange={setDefaultEditor}
/>
</div>

View File

@@ -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();
}

View File

@@ -17,6 +17,7 @@ import {
ComponentViewer,
SNTag,
NoteViewController,
SNTheme,
} from '@standardnotes/snjs';
import pull from 'lodash/pull';
import {