kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Merge branch 'improve-dark-mode' into 'develop'
Support System Theme (light / dark mode) Closes #818 See merge request soapbox-pub/soapbox-fe!1305api-accept
commit
7e8a53c4cf
|
@ -33,7 +33,7 @@ export const defaultSettings = ImmutableMap({
|
||||||
missingDescriptionModal: false,
|
missingDescriptionModal: false,
|
||||||
defaultPrivacy: 'public',
|
defaultPrivacy: 'public',
|
||||||
defaultContentType: 'text/plain',
|
defaultContentType: 'text/plain',
|
||||||
themeMode: 'light',
|
themeMode: 'system',
|
||||||
locale: navigator.language.split(/[-_]/)[0] || 'en',
|
locale: navigator.language.split(/[-_]/)[0] || 'en',
|
||||||
showExplanationBox: true,
|
showExplanationBox: true,
|
||||||
explanationBox: true,
|
explanationBox: true,
|
||||||
|
|
|
@ -86,8 +86,17 @@ const SoapboxMount = () => {
|
||||||
const [localeLoading, setLocaleLoading] = useState(true);
|
const [localeLoading, setLocaleLoading] = useState(true);
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
|
const colorSchemeQueryList = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const [isSystemDarkMode, setSystemDarkMode] = useState(colorSchemeQueryList.matches);
|
||||||
|
const userTheme = settings.get('themeMode');
|
||||||
|
const darkMode = userTheme === 'dark' || (userTheme === 'system' && isSystemDarkMode);
|
||||||
|
|
||||||
const themeCss = generateThemeCss(soapboxConfig);
|
const themeCss = generateThemeCss(soapboxConfig);
|
||||||
|
|
||||||
|
const handleSystemModeChange = (event: MediaQueryListEvent) => {
|
||||||
|
setSystemDarkMode(event.matches);
|
||||||
|
};
|
||||||
|
|
||||||
// Load the user's locale
|
// Load the user's locale
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
MESSAGES[locale]().then(messages => {
|
MESSAGES[locale]().then(messages => {
|
||||||
|
@ -105,6 +114,12 @@ const SoapboxMount = () => {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
colorSchemeQueryList.addEventListener('change', handleSystemModeChange);
|
||||||
|
|
||||||
|
return () => colorSchemeQueryList.removeEventListener('change', handleSystemModeChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// @ts-ignore: I don't actually know what these should be, lol
|
// @ts-ignore: I don't actually know what these should be, lol
|
||||||
const shouldUpdateScroll = (prevRouterProps, { location }) => {
|
const shouldUpdateScroll = (prevRouterProps, { location }) => {
|
||||||
return !(location.state?.soapboxModalKey && location.state?.soapboxModalKey !== prevRouterProps?.location?.state?.soapboxModalKey);
|
return !(location.state?.soapboxModalKey && location.state?.soapboxModalKey !== prevRouterProps?.location?.state?.soapboxModalKey);
|
||||||
|
@ -128,7 +143,7 @@ const SoapboxMount = () => {
|
||||||
return (
|
return (
|
||||||
<IntlProvider locale={locale} messages={messages}>
|
<IntlProvider locale={locale} messages={messages}>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<html lang={locale} className={classNames({ dark: settings.get('themeMode') === 'dark' })} />
|
<html lang={locale} className={classNames({ dark: darkMode })} />
|
||||||
<body className={bodyClass} />
|
<body className={bodyClass} />
|
||||||
{themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
|
{themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
|
||||||
<meta name='theme-color' content={soapboxConfig.brandColor} />
|
<meta name='theme-color' content={soapboxConfig.brandColor} />
|
||||||
|
@ -147,7 +162,7 @@ const SoapboxMount = () => {
|
||||||
return (
|
return (
|
||||||
<IntlProvider locale={locale} messages={messages}>
|
<IntlProvider locale={locale} messages={messages}>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<html lang={locale} className={classNames({ dark: settings.get('themeMode') === 'dark' })} />
|
<html lang={locale} className={classNames({ dark: darkMode })} />
|
||||||
<body className={bodyClass} />
|
<body className={bodyClass} />
|
||||||
{themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
|
{themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
|
||||||
<meta name='theme-color' content={soapboxConfig.brandColor} />
|
<meta name='theme-color' content={soapboxConfig.brandColor} />
|
||||||
|
|
|
@ -23,7 +23,7 @@ import {
|
||||||
FileChooserLogo,
|
FileChooserLogo,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
} from 'soapbox/features/forms';
|
} from 'soapbox/features/forms';
|
||||||
import ThemeToggle from 'soapbox/features/ui/components/theme_toggle';
|
import ThemeToggle from 'soapbox/features/ui/components/theme-toggle';
|
||||||
import { isMobile } from 'soapbox/is_mobile';
|
import { isMobile } from 'soapbox/is_mobile';
|
||||||
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
|
import { normalizeSoapboxConfig } from 'soapbox/normalizers';
|
||||||
|
|
||||||
|
|
|
@ -6,8 +6,7 @@ import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { Avatar, Button, Icon } from 'soapbox/components/ui';
|
import { Avatar, Button, Icon } from 'soapbox/components/ui';
|
||||||
import Search from 'soapbox/features/compose/components/search';
|
import Search from 'soapbox/features/compose/components/search';
|
||||||
import ThemeToggle from 'soapbox/features/ui/components/theme_toggle';
|
import { useOwnAccount, useSoapboxConfig, useSettings } from 'soapbox/hooks';
|
||||||
import { useOwnAccount, useSoapboxConfig, useSettings, useFeatures } from 'soapbox/hooks';
|
|
||||||
|
|
||||||
import { openSidebar } from '../../../actions/sidebar';
|
import { openSidebar } from '../../../actions/sidebar';
|
||||||
|
|
||||||
|
@ -19,7 +18,6 @@ const Navbar = () => {
|
||||||
|
|
||||||
const account = useOwnAccount();
|
const account = useOwnAccount();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const features = useFeatures();
|
|
||||||
const soapboxConfig = useSoapboxConfig();
|
const soapboxConfig = useSoapboxConfig();
|
||||||
const singleUserMode = soapboxConfig.get('singleUserMode');
|
const singleUserMode = soapboxConfig.get('singleUserMode');
|
||||||
|
|
||||||
|
@ -69,11 +67,6 @@ const Navbar = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='absolute inset-y-0 right-0 flex items-center pr-2 lg:static lg:inset-auto lg:ml-6 lg:pr-0 space-x-3'>
|
<div className='absolute inset-y-0 right-0 flex items-center pr-2 lg:static lg:inset-auto lg:ml-6 lg:pr-0 space-x-3'>
|
||||||
{/* TODO: make this available for everyone when it's ready (possibly in a different place) */}
|
|
||||||
{(features.darkMode || settings.get('isDeveloper')) && (
|
|
||||||
<ThemeToggle />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{account ? (
|
{account ? (
|
||||||
<div className='hidden relative lg:flex items-center'>
|
<div className='hidden relative lg:flex items-center'>
|
||||||
<ProfileDropdown account={account}>
|
<ProfileDropdown account={account}>
|
||||||
|
|
|
@ -7,15 +7,18 @@ import { Link } from 'react-router-dom';
|
||||||
import { logOut, switchAccount } from 'soapbox/actions/auth';
|
import { logOut, switchAccount } from 'soapbox/actions/auth';
|
||||||
import { fetchOwnAccounts } from 'soapbox/actions/auth';
|
import { fetchOwnAccounts } from 'soapbox/actions/auth';
|
||||||
import { Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
|
import { Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui';
|
||||||
import { useAppSelector } from 'soapbox/hooks';
|
import { useAppSelector, useFeatures, useSettings } from 'soapbox/hooks';
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
|
|
||||||
import Account from '../../../components/account';
|
import Account from '../../../components/account';
|
||||||
|
|
||||||
|
import ThemeToggle from './theme-toggle';
|
||||||
|
|
||||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
add: { id: 'profile_dropdown.add_account', defaultMessage: 'Add an existing account' },
|
add: { id: 'profile_dropdown.add_account', defaultMessage: 'Add an existing account' },
|
||||||
|
theme: { id: 'profile_dropdown.theme', defaultMessage: 'Theme' },
|
||||||
logout: { id: 'profile_dropdown.logout', defaultMessage: 'Log out @{acct}' },
|
logout: { id: 'profile_dropdown.logout', defaultMessage: 'Log out @{acct}' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -24,9 +27,10 @@ interface IProfileDropdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
type IMenuItem = {
|
type IMenuItem = {
|
||||||
text: string | React.ReactElement | null,
|
text: string | React.ReactElement | null
|
||||||
to?: string,
|
to?: string
|
||||||
icon?: string,
|
toggle?: JSX.Element
|
||||||
|
icon?: string
|
||||||
action?: (event: React.MouseEvent) => void
|
action?: (event: React.MouseEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,6 +38,8 @@ const getAccount = makeGetAccount();
|
||||||
|
|
||||||
const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
|
const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const features = useFeatures();
|
||||||
|
const settings = useSettings();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const authUsers = useAppSelector((state) => state.auth.get('users'));
|
const authUsers = useAppSelector((state) => state.auth.get('users'));
|
||||||
|
@ -73,6 +79,12 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (features.darkMode || settings.get('isDeveloper')) {
|
||||||
|
menu.push({ text: null });
|
||||||
|
|
||||||
|
menu.push({ text: intl.formatMessage(messages.theme), toggle: <ThemeToggle /> });
|
||||||
|
}
|
||||||
|
|
||||||
menu.push({ text: null });
|
menu.push({ text: null });
|
||||||
|
|
||||||
menu.push({
|
menu.push({
|
||||||
|
@ -89,7 +101,7 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return menu;
|
return menu;
|
||||||
}, [account, authUsers]);
|
}, [account, authUsers, features]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetchOwnAccountThrottled();
|
fetchOwnAccountThrottled();
|
||||||
|
@ -103,7 +115,15 @@ const ProfileDropdown: React.FC<IProfileDropdown> = ({ account, children }) => {
|
||||||
|
|
||||||
<MenuList>
|
<MenuList>
|
||||||
{menu.map((menuItem, idx) => {
|
{menu.map((menuItem, idx) => {
|
||||||
if (!menuItem.text) {
|
if (menuItem.toggle) {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-row items-center justify-between px-4 py-1 text-sm text-gray-700 dark:text-gray-400'>
|
||||||
|
<span>{menuItem.text}</span>
|
||||||
|
|
||||||
|
{menuItem.toggle}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (!menuItem.text) {
|
||||||
return <MenuDivider key={idx} />;
|
return <MenuDivider key={idx} />;
|
||||||
} else {
|
} else {
|
||||||
const Comp: any = menuItem.action ? MenuItem : MenuLink;
|
const Comp: any = menuItem.action ? MenuItem : MenuLink;
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
|
||||||
|
import { changeSetting } from 'soapbox/actions/settings';
|
||||||
|
import { Icon } from 'soapbox/components/ui';
|
||||||
|
import { useSettings } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
light: { id: 'theme_toggle.light', defaultMessage: 'Light' },
|
||||||
|
dark: { id: 'theme_toggle.dark', defaultMessage: 'Dark' },
|
||||||
|
system: { id: 'theme_toggle.system', defaultMessage: 'System' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IThemeToggle {
|
||||||
|
showLabel?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeToggle = ({ showLabel }: IThemeToggle) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const themeMode = useSettings().get('themeMode');
|
||||||
|
|
||||||
|
const onToggle = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
dispatch(changeSetting(['themeMode'], event.target.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const themeIconSrc = useMemo(() => {
|
||||||
|
switch (themeMode) {
|
||||||
|
case 'system':
|
||||||
|
return require('@tabler/icons/icons/device-desktop.svg');
|
||||||
|
case 'light':
|
||||||
|
return require('@tabler/icons/icons/sun.svg');
|
||||||
|
case 'dark':
|
||||||
|
return require('@tabler/icons/icons/moon.svg');
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
<div className='relative rounded-md shadow-sm'>
|
||||||
|
<div className='absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none'>
|
||||||
|
<Icon src={themeIconSrc} className='h-4 w-4 text-gray-400' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
onChange={onToggle}
|
||||||
|
defaultValue={themeMode}
|
||||||
|
className='focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-800 dark:border-gray-600 block w-full pl-8 pr-12 sm:text-sm border-gray-300 rounded-md'
|
||||||
|
>
|
||||||
|
<option value='system'>{intl.formatMessage(messages.system)}</option>
|
||||||
|
<option value='light'>{intl.formatMessage(messages.light)}</option>
|
||||||
|
<option value='dark'>{intl.formatMessage(messages.dark)}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className='absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none'>
|
||||||
|
<Icon src={require('@tabler/icons/icons/chevron-down.svg')} className='h-4 w-4 text-gray-400' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeToggle;
|
|
@ -1,51 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
import Toggle from 'react-toggle';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
import { changeSetting } from 'soapbox/actions/settings';
|
|
||||||
import { Icon } from 'soapbox/components/ui';
|
|
||||||
import { useSettings } from 'soapbox/hooks';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
switchToLight: { id: 'tabs_bar.theme_toggle_light', defaultMessage: 'Switch to light theme' },
|
|
||||||
switchToDark: { id: 'tabs_bar.theme_toggle_dark', defaultMessage: 'Switch to dark theme' },
|
|
||||||
});
|
|
||||||
|
|
||||||
interface IThemeToggle {
|
|
||||||
showLabel?: boolean,
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThemeToggle({ showLabel }: IThemeToggle) {
|
|
||||||
const intl = useIntl();
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const themeMode = useSettings().get('themeMode');
|
|
||||||
|
|
||||||
const id = uuidv4();
|
|
||||||
const label = intl.formatMessage(themeMode === 'light' ? messages.switchToDark : messages.switchToLight);
|
|
||||||
|
|
||||||
const onToggle = () => {
|
|
||||||
const setting = themeMode === 'light' ? 'dark' : 'light';
|
|
||||||
dispatch(changeSetting(['themeMode'], setting));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='theme-toggle'>
|
|
||||||
<div className='setting-toggle' aria-label={label}>
|
|
||||||
<Toggle
|
|
||||||
id={id}
|
|
||||||
checked={themeMode === 'light'}
|
|
||||||
icons={{
|
|
||||||
checked: <Icon className='w-4 h-4' src={require('@tabler/icons/icons/sun.svg')} />,
|
|
||||||
unchecked: <Icon className='w-4 h-4' src={require('@tabler/icons/icons/moon.svg')} />,
|
|
||||||
}}
|
|
||||||
onChange={onToggle}
|
|
||||||
/>
|
|
||||||
{showLabel && (<label htmlFor={id} className='setting-toggle__label'>{label}</label>)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ThemeToggle;
|
|
Ładowanie…
Reference in New Issue