feat(ui): add syncable dark mode (#56243)

Co-authored-by: Ahmad Abdolsaheb <ahmad.abdolsaheb@gmail.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
Co-authored-by: Sem Bauke <semboot699@gmail.com>
This commit is contained in:
Anna
2024-12-10 10:42:09 -05:00
committed by GitHub
parent 674a31811e
commit 0b77e59457
31 changed files with 218 additions and 225 deletions

View File

@@ -14,7 +14,7 @@ import MicrosoftLogo from '../assets/icons/microsoft-logo';
import { createFlashMessage } from '../components/Flash/redux';
import { Loader } from '../components/helpers';
import RedirectHome from '../components/redirect-home';
import { Themes } from '../components/settings/theme';
import { LocalStorageThemes } from '../redux/types';
import { showCert, fetchProfileForUser } from '../redux/actions';
import {
showCertSelector,
@@ -273,7 +273,7 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => {
data-playwright-test-label='donation-form'
>
<MultiTierDonationForm
defaultTheme={Themes.Default}
defaultTheme={LocalStorageThemes.Light}
handleProcessing={handleProcessing}
isMinimalForm={true}
paymentContext={PaymentContext.Certificate}

View File

@@ -42,7 +42,7 @@ const loggedInProps = {
navigate: navigate,
showLoading: false,
submitNewAbout: jest.fn(),
toggleNightMode: jest.fn(),
toggleTheme: jest.fn(),
updateSocials: jest.fn(),
updateIsHonest: jest.fn(),
updatePortfolio: jest.fn(),

View File

@@ -17,7 +17,6 @@ import DangerZone from '../components/settings/danger-zone';
import Email from '../components/settings/email';
import Honesty from '../components/settings/honesty';
import Privacy from '../components/settings/privacy';
import { type ThemeProps, Themes } from '../components/settings/theme';
import UserToken from '../components/settings/user-token';
import ExamToken from '../components/settings/exam-token';
import { hardGoTo as navigate } from '../redux/actions';
@@ -33,7 +32,6 @@ import {
updateMyHonesty,
updateMyQuincyEmail,
updateMySound,
updateMyTheme,
updateMyKeyboardShortcuts,
verifyCert,
resetMyEditorLayout
@@ -42,7 +40,7 @@ import {
const { apiLocation } = envData;
// TODO: update types for actions
type ShowSettingsProps = Pick<ThemeProps, 'toggleNightMode'> & {
type ShowSettingsProps = {
createFlashMessage: typeof createFlashMessage;
isSignedIn: boolean;
navigate: (location: string) => void;
@@ -75,7 +73,6 @@ const mapDispatchToProps = {
createFlashMessage,
navigate,
submitNewAbout,
toggleNightMode: (theme: Themes) => updateMyTheme({ theme }),
toggleSoundMode: (sound: boolean) => updateMySound({ sound }),
toggleKeyboardShortcuts: (keyboardShortcuts: boolean) =>
updateMyKeyboardShortcuts({ keyboardShortcuts }),
@@ -91,7 +88,6 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
const {
createFlashMessage,
isSignedIn,
toggleNightMode,
toggleSoundMode,
toggleKeyboardShortcuts,
resetEditorLayout,
@@ -121,7 +117,6 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
isHonest,
sendQuincyEmail,
username,
theme,
keyboardShortcuts
},
navigate,
@@ -163,13 +158,11 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
{t('settings.for', { username: username })}
</h1>
<MiscSettings
currentTheme={theme}
keyboardShortcuts={keyboardShortcuts}
sound={sound}
editorLayout={editorLayout}
resetEditorLayout={resetEditorLayout}
toggleKeyboardShortcuts={toggleKeyboardShortcuts}
toggleNightMode={toggleNightMode}
toggleSoundMode={toggleSoundMode}
/>
<Spacer size='m' />

View File

@@ -21,10 +21,10 @@ import {
isDonatingSelector,
signInLoadingSelector,
donationFormStateSelector,
completedChallengesSelector
completedChallengesSelector,
themeSelector
} from '../../redux/selectors';
import { Themes } from '../settings/theme';
import { DonateFormState } from '../../redux/types';
import { LocalStorageThemes, DonateFormState } from '../../redux/types';
import type { CompletedChallenge } from '../../redux/prop-types';
import { CENTS_IN_DOLLAR, formattedAmountLabel } from './utils';
import DonateCompletion from './donate-completion';
@@ -61,7 +61,7 @@ type PostCharge = (data: {
type DonateFormProps = {
postCharge: PostCharge;
defaultTheme?: Themes;
defaultTheme?: LocalStorageThemes;
email: string;
handleProcessing?: () => void;
editAmount?: () => void;
@@ -72,10 +72,10 @@ type DonateFormProps = {
isDonating: boolean;
showLoading: boolean;
t: TFunction;
theme: Themes;
updateDonationFormState: (state: DonationApprovalData) => unknown;
paymentContext: PaymentContext;
completedChallenges: CompletedChallenge[];
theme: LocalStorageThemes;
};
const mapStateToProps = createSelector(
@@ -85,21 +85,23 @@ const mapStateToProps = createSelector(
donationFormStateSelector,
userSelector,
completedChallengesSelector,
themeSelector,
(
showLoading: DonateFormProps['showLoading'],
isSignedIn: DonateFormProps['isSignedIn'],
isDonating: DonateFormProps['isDonating'],
donationFormState: DonateFormState,
{ email, theme }: { email: string; theme: Themes },
completedChallenges: CompletedChallenge[]
{ email }: { email: string },
completedChallenges: CompletedChallenge[],
theme: LocalStorageThemes
) => ({
isSignedIn,
isDonating,
showLoading,
donationFormState,
email,
theme,
completedChallenges
completedChallenges,
theme
})
);

View File

@@ -17,7 +17,7 @@ import {
defaultTierAmount,
type DonationAmount
} from '../../../../shared/config/donation-settings'; // You can further extract these into separate components and import them
import { Themes } from '../settings/theme';
import { LocalStorageThemes } from '../../redux/types';
import { formattedAmountLabel, convertToTimeContributed } from './utils';
import DonateForm from './donate-form';
@@ -26,7 +26,7 @@ type MultiTierDonationFormProps = {
handleProcessing?: () => void;
paymentContext: PaymentContext;
isMinimalForm?: boolean;
defaultTheme?: Themes;
defaultTheme?: LocalStorageThemes;
isAnimationEnabled?: boolean;
};
function SelectionTabs({

View File

@@ -12,7 +12,7 @@ import {
} from '../../../../shared/config/donation-settings';
import envData from '../../../config/env.json';
import { userSelector, signInLoadingSelector } from '../../redux/selectors';
import { Themes } from '../settings/theme';
import { LocalStorageThemes } from '../../redux/types';
import { DonationApprovalData, PostPayment } from './types';
import PayPalButtonScriptLoader from './paypal-button-script-loader';
@@ -34,7 +34,7 @@ type PaypalButtonProps = {
isPaypalLoading: boolean;
t: (label: string) => string;
ref?: Ref<PaypalButton>;
theme: Themes;
theme: LocalStorageThemes;
isSubscription?: boolean;
handlePaymentButtonLoad: (provider: 'stripe' | 'paypal') => void;
isMinimalForm: boolean | undefined;
@@ -91,7 +91,7 @@ class PaypalButton extends Component<PaypalButtonProps, PaypalButtonState> {
const { duration, planId, amount } = this.state;
const { t, theme, isPaypalLoading, isMinimalForm } = this.props;
const isSubscription = duration !== 'one-time';
const buttonColor = theme === Themes.Night ? 'white' : 'gold';
const buttonColor = theme === LocalStorageThemes.Dark ? 'white' : 'gold';
if (!paypalClientId) {
return null;
}

View File

@@ -11,14 +11,14 @@ import type {
import React, { useState } from 'react';
import { PaymentProvider } from '../../../../shared/config/donation-settings';
import { Themes } from '../settings/theme';
import { LocalStorageThemes } from '../../redux/types';
import { DonationApprovalData, PostPayment } from './types';
interface FormPropTypes {
onDonationStateChange: (donationState: DonationApprovalData) => void;
postPayment: (arg0: PostPayment) => void;
t: (label: string) => string;
theme: Themes;
theme: LocalStorageThemes;
processing: boolean;
}
@@ -80,7 +80,7 @@ export default function StripeCardForm({
base: {
fontSize: '18px',
fontFamily: 'Lato, sans-serif',
color: `${theme === Themes.Night ? '#fff' : '#0a0a23'}`,
color: `${theme === LocalStorageThemes.Dark ? '#fff' : '#0a0a23'}`,
'::placeholder': {
color: `#858591`
}

View File

@@ -6,7 +6,7 @@ import type { PaymentRequest, Stripe } from '@stripe/stripe-js';
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Themes } from '../settings/theme';
import { LocalStorageThemes } from '../../redux/types';
import {
PaymentProvider,
DonationDuration
@@ -17,7 +17,7 @@ import { DonationApprovalData, PostPayment } from './types';
interface WrapperProps {
label: string;
amount: number;
theme: Themes;
theme: LocalStorageThemes;
duration: DonationDuration;
postPayment: (arg0: PostPayment) => void;
onDonationStateChange: (donationState: DonationApprovalData) => void;
@@ -145,7 +145,7 @@ const WalletsButton = ({
style: {
paymentRequestButton: {
type: 'default',
theme: theme === Themes.Night ? 'light' : 'dark',
theme: theme === LocalStorageThemes.Light ? 'light' : 'dark',
height: '43px'
}
},

View File

@@ -4,10 +4,10 @@ import {
FlashState,
State,
FlashApp,
FlashMessageArg
FlashMessageArg,
LocalStorageThemes
} from '../../../redux/types';
import { playTone } from '../../../utils/tone';
import { Themes } from '../../settings/theme';
import { FlashMessages } from './flash-messages';
export const flashMessageSelector = (state: State): FlashState['message'] =>
@@ -33,7 +33,7 @@ export const createFlashMessage = (
): ReducerPayload<FlashActionTypes.CreateFlashMessage> => {
// Nightmode theme has special tones
if (flash.variables?.theme) {
void playTone(flash.variables.theme as Themes);
void playTone(flash.variables.theme as LocalStorageThemes);
} else if (flash.message !== FlashMessages.None) {
void playTone(flash.message);
}

View File

@@ -4,31 +4,39 @@ import {
faExternalLinkAlt
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { Fragment } from 'react';
import React from 'react';
import { useTranslation, withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { radioLocation } from '../../../../config/env.json';
import { openSignoutModal } from '../../../redux/actions';
import { updateMyTheme } from '../../../redux/settings/actions';
import { openSignoutModal, toggleTheme } from '../../../redux/actions';
import { Link } from '../../helpers';
import { type ThemeProps, Themes } from '../../settings/theme';
import { LocalStorageThemes } from '../../../redux/types';
import { themeSelector } from '../../../redux/selectors';
import { User } from '../../../redux/prop-types';
import SupporterBadge from '../../../assets/icons/supporter-badge';
export interface NavLinksProps extends Pick<ThemeProps, 'toggleNightMode'> {
export interface NavLinksProps {
displayMenu: boolean;
showMenu: () => void;
hideMenu: () => void;
user?: User;
menuButtonRef: React.RefObject<HTMLButtonElement>;
openSignoutModal: () => void;
theme: LocalStorageThemes;
toggleTheme: () => void;
}
const mapDispatchToProps = {
toggleNightMode: (theme: Themes) => updateMyTheme({ theme }),
toggleTheme,
openSignoutModal
};
const mapStateToProps = createSelector(
themeSelector,
(theme: LocalStorageThemes) => ({ theme })
);
interface DonateButtonProps {
isUserDonating: boolean | undefined;
handleMenuKeyDown: (event: React.KeyboardEvent<HTMLAnchorElement>) => void;
@@ -65,29 +73,17 @@ const DonateButton = ({
);
};
const toggleTheme = (
currentTheme = Themes.Default,
toggleNightMode: typeof updateMyTheme
) => {
toggleNightMode(
currentTheme === Themes.Night ? Themes.Default : Themes.Night
);
};
function NavLinks({
menuButtonRef,
openSignoutModal,
hideMenu,
displayMenu,
toggleNightMode,
user
user,
theme,
toggleTheme
}: NavLinksProps) {
const { t } = useTranslation();
const {
isDonating: isUserDonating,
username: currentUserName,
theme: currentUserTheme
} = user || {};
const { isDonating: isUserDonating, username: currentUserName } = user || {};
// the accessibility tree just needs a little more time to pick up the change.
// This function allows us to set aria-expanded to false and then delay just a bit before setting focus on the button
@@ -249,40 +245,16 @@ function NavLinks({
</li>
<li className='nav-line' key='theme'>
<button
{...(!currentUserName && { 'aria-describedby': 'theme-sign-in' })}
aria-disabled={!currentUserName}
aria-pressed={currentUserTheme === Themes.Night ? 'true' : 'false'}
className={
'nav-link nav-link-flex' +
(!currentUserName ? ' nav-link-header' : '')
}
onClick={() => {
if (currentUserName) {
toggleTheme(currentUserTheme, toggleNightMode);
}
}}
aria-pressed={theme === LocalStorageThemes.Dark}
className={'nav-link nav-link-flex'}
onClick={toggleTheme}
onKeyDown={currentUserName ? handleMenuKeyDown : handleSignOutKeys}
>
{currentUserName ? (
<>
<span>{t('settings.labels.night-mode')}</span>
{currentUserTheme === Themes.Night ? (
<FontAwesomeIcon icon={faCheckSquare} />
) : (
<FontAwesomeIcon icon={faSquare} />
)}
</>
<span>{t('settings.labels.night-mode')}</span>
{theme === LocalStorageThemes.Dark ? (
<FontAwesomeIcon icon={faCheckSquare} />
) : (
<Fragment key='night-mode'>
<span className='sr-only'>{t('settings.labels.night-mode')}</span>
<span
aria-hidden='true'
className='nav-link-dull'
id='theme-sign-in'
>
{t('misc.change-theme')}
</span>
</Fragment>
<FontAwesomeIcon icon={faSquare} />
)}
</button>
</li>
@@ -304,4 +276,7 @@ function NavLinks({
NavLinks.displayName = 'NavLinks';
export default connect(null, mapDispatchToProps)(withTranslation()(NavLinks));
export default connect(
mapStateToProps,
mapDispatchToProps
)(withTranslation()(NavLinks));

View File

@@ -19,7 +19,7 @@ const SearchBarOptimized = Loadable(
type UniversalNavProps = Omit<
NavLinksProps,
'toggleNightMode' | 'openSignoutModal'
'toggleTheme' | 'openSignoutModal'
> & {
fetchState: { pending: boolean };
searchBarRef?: React.RefObject<HTMLDivElement>;

View File

@@ -23,6 +23,7 @@ import hackZeroSlashRegularURL from '../../../static/fonts/hack-zeroslash/Hack-Z
import { isBrowser } from '../../../utils';
import {
fetchUser,
initializeTheme,
onlineStatusChange,
serverStatusChange
} from '../../redux/actions';
@@ -32,7 +33,8 @@ import {
userSelector,
isOnlineSelector,
isServerOnlineSelector,
userFetchStateSelector
userFetchStateSelector,
themeSelector
} from '../../redux/selectors';
import { UserFetchState, User } from '../../redux/prop-types';
@@ -56,6 +58,7 @@ import './fonts.css';
import './global.css';
import './variables.css';
import './rtl-layout.css';
import { LocalStorageThemes } from '../../redux/types';
const mapStateToProps = createSelector(
isSignedInSelector,
@@ -65,6 +68,7 @@ const mapStateToProps = createSelector(
isServerOnlineSelector,
userFetchStateSelector,
userSelector,
themeSelector,
(
isSignedIn,
examInProgress: boolean,
@@ -72,7 +76,8 @@ const mapStateToProps = createSelector(
isOnline: boolean,
isServerOnline: boolean,
fetchState: UserFetchState,
user: User
user: User,
theme: LocalStorageThemes
) => ({
isSignedIn,
examInProgress,
@@ -81,8 +86,8 @@ const mapStateToProps = createSelector(
isOnline,
isServerOnline,
fetchState,
theme: user.theme,
user
user,
theme
})
);
@@ -94,7 +99,8 @@ const mapDispatchToProps = (dispatch: Dispatch) =>
fetchUser,
removeFlashMessage,
onlineStatusChange,
serverStatusChange
serverStatusChange,
initializeTheme
},
dispatch
);
@@ -112,13 +118,6 @@ interface DefaultLayoutProps extends StateProps, DispatchProps {
superBlock?: string;
}
const getSystemTheme = () =>
`${
window.matchMedia('(prefers-color-scheme: dark)').matches === true
? 'dark-palette'
: 'light-palette'
}`;
function DefaultLayout({
children,
hasMessage,
@@ -137,7 +136,8 @@ function DefaultLayout({
theme,
user,
pathname,
fetchUser
fetchUser,
initializeTheme
}: DefaultLayoutProps): JSX.Element {
const { t } = useTranslation();
const isMobileLayout = useMediaQuery({ maxWidth: MAX_MOBILE_WIDTH });
@@ -148,6 +148,12 @@ function DefaultLayout({
const isExSmallViewportHeight = useMediaQuery({
maxHeight: EX_SMALL_VIEWPORT_HEIGHT
});
useEffect(() => {
initializeTheme();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
// componentDidMount
if (!isSignedIn) {
@@ -170,8 +176,6 @@ function DefaultLayout({
return typeof isOnline === 'boolean' ? onlineStatusChange(isOnline) : null;
};
const useSystemTheme = fetchState.complete && isSignedIn === false;
const isJapanese = clientLocale === 'japanese';
if (fetchState.pending) {
@@ -183,9 +187,7 @@ function DefaultLayout({
envData.environment === 'production' && <StagingWarningModal />}
<Helmet
bodyAttributes={{
class: useSystemTheme
? getSystemTheme()
: `${String(theme) === 'night' ? 'dark' : 'light'}-palette`
class: `${theme}-palette`
}}
meta={[
{

View File

@@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import { Themes } from '../settings/theme';
import { UserThemes } from '../../redux/types';
import Profile from './profile';
jest.mock('../../analytics');
@@ -46,7 +46,7 @@ const userProps = {
sendQuincyEmail: true,
sound: true,
keyboardShortcuts: false,
theme: Themes.Default,
theme: UserThemes.Default,
twitter: 'string',
username: 'string',
website: 'string',

View File

@@ -2,30 +2,26 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Spacer } from '@freecodecamp/ui';
import { FullWidthRow } from '../helpers';
import ThemeSettings, { ThemeProps } from '../../components/settings/theme';
import SoundSettings from '../../components/settings/sound';
import KeyboardShortcutsSettings from '../../components/settings/keyboard-shortcuts';
import ScrollbarWidthSettings from '../../components/settings/scrollbar-width';
type MiscSettingsProps = ThemeProps & {
currentTheme: string;
type MiscSettingsProps = {
keyboardShortcuts: boolean;
sound: boolean;
editorLayout: boolean | null;
toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void;
toggleNightMode: () => void;
toggleSoundMode: (sound: boolean) => void;
resetEditorLayout: () => void;
};
const MiscSettings = ({
currentTheme,
keyboardShortcuts,
sound,
editorLayout,
resetEditorLayout,
toggleKeyboardShortcuts,
toggleNightMode,
toggleSoundMode
}: MiscSettingsProps) => {
const { t } = useTranslation();
@@ -34,10 +30,6 @@ const MiscSettings = ({
<>
<Spacer size='m' />
<FullWidthRow>
<ThemeSettings
currentTheme={currentTheme}
toggleNightMode={toggleNightMode}
/>
<SoundSettings sound={sound} toggleSoundMode={toggleSoundMode} />
<KeyboardShortcutsSettings
keyboardShortcuts={keyboardShortcuts}

View File

@@ -1,39 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { updateMyTheme } from '../../redux/settings/actions';
import ToggleButtonSetting from './toggle-button-setting';
export enum Themes {
Night = 'night',
Default = 'default'
}
export type ThemeProps = {
currentTheme: Themes;
toggleNightMode: typeof updateMyTheme;
};
export default function ThemeSettings({
currentTheme,
toggleNightMode
}: ThemeProps): JSX.Element {
const { t } = useTranslation();
return (
<ToggleButtonSetting
action={t('settings.labels.night-mode')}
flag={currentTheme === Themes.Night}
flagName='currentTheme'
offLabel={t('buttons.off')}
onLabel={t('buttons.on')}
toggleFlag={() => {
toggleNightMode(
currentTheme === Themes.Night ? Themes.Default : Themes.Night
);
}}
/>
);
}
ThemeSettings.displayName = 'ThemeSettings';

View File

@@ -4,6 +4,9 @@ export const ns = 'app';
export const actionTypes = createTypes(
[
'setTheme',
'initializeTheme',
'toggleTheme',
'appMount',
'hardGoTo',
'allowBlockDonationRequests',

View File

@@ -52,6 +52,10 @@ export const fetchUser = createAction(actionTypes.fetchUser);
export const fetchUserComplete = createAction(actionTypes.fetchUserComplete);
export const fetchUserError = createAction(actionTypes.fetchUserError);
export const toggleTheme = createAction(actionTypes.toggleTheme);
export const setTheme = createAction(actionTypes.setTheme);
export const initializeTheme = createAction(actionTypes.initializeTheme);
export const updateAllChallengesInfo = createAction(
actionTypes.updateAllChallengesInfo
);

View File

@@ -23,6 +23,7 @@ import { createUserTokenSaga } from './user-token-saga';
import { createMsUsernameSaga } from './ms-username-saga';
import { createSurveySaga } from './survey-saga';
import { createSessionCompletedChallengesSaga } from './session-completed-challenges';
import { createThemeSaga } from './theme-saga';
const defaultFetchState = {
pending: true,
@@ -55,6 +56,7 @@ const initialState = {
currentChallengeId: store.get(CURRENT_CHALLENGE_KEY),
examInProgress: false,
isProcessing: false,
theme: 'light',
showCert: {},
showCertFetchState: {
...defaultFetchState
@@ -87,6 +89,7 @@ export const epics = [hardGoToEpic, failedUpdatesEpic, updateCompleteEpic];
export const sagas = [
...createAcceptTermsSaga(actionTypes),
...createThemeSaga(actionTypes),
...createAppMountSaga(actionTypes),
...createDonationSaga(actionTypes),
...createFetchUserSaga(actionTypes),
@@ -253,6 +256,10 @@ export const reducer = handleActions(
error: payload
}
}),
[actionTypes.setTheme]: (state, { payload: theme }) => ({
...state,
theme
}),
[actionTypes.onlineStatusChange]: (state, { payload: isOnline }) => ({
...state,
isOnline
@@ -481,8 +488,6 @@ export const reducer = handleActions(
payload ? spreadThePayloadOnUser(state, payload) : state,
[settingsTypes.updateMySoundComplete]: (state, { payload }) =>
payload ? spreadThePayloadOnUser(state, payload) : state,
[settingsTypes.updateMyThemeComplete]: (state, { payload }) =>
payload ? spreadThePayloadOnUser(state, payload) : state,
[settingsTypes.updateMyKeyboardShortcutsComplete]: (state, { payload }) =>
payload ? spreadThePayloadOnUser(state, payload) : state,
[settingsTypes.updateMyHonestyComplete]: (state, { payload }) =>

View File

@@ -2,8 +2,8 @@ import { HandlerProps } from 'react-reflex';
import { SuperBlocks } from '../../../shared/config/curriculum';
import { BlockLayouts, BlockTypes } from '../../../shared/config/blocks';
import type { ChallengeFile, Ext } from '../../../shared/utils/polyvinyl';
import { Themes } from '../components/settings/theme';
import { type CertTitle } from '../../config/cert-and-project-map';
import { UserThemes } from './types';
export type { ChallengeFile, Ext };
@@ -316,7 +316,7 @@ export type User = {
savedChallenges: SavedChallenges;
sendQuincyEmail: boolean;
sound: boolean;
theme: Themes;
theme: UserThemes;
keyboardShortcuts: boolean;
twitter: string;
username: string;

View File

@@ -259,6 +259,10 @@ export const allChallengesInfoSelector = state =>
export const userProfileFetchStateSelector = state =>
state[MainApp].userProfileFetchState;
export const usernameSelector = state => state[MainApp].appUsername;
export const themeSelector = state => state[MainApp].theme;
export const userThemeSelector = state => {
return userSelector(state).theme;
};
export const userSelector = state => {
const username = usernameSelector(state);

View File

@@ -10,7 +10,6 @@ export const actionTypes = createTypes(
...createAsyncTypes('updateMyEmail'),
...createAsyncTypes('updateMySocials'),
...createAsyncTypes('updateMySound'),
...createAsyncTypes('updateMyTheme'),
...createAsyncTypes('updateMyKeyboardShortcuts'),
...createAsyncTypes('updateMyHonesty'),
...createAsyncTypes('updateMyQuincyEmail'),

View File

@@ -46,13 +46,6 @@ export const updateMySoundComplete = createAction(
);
export const updateMySoundError = createAction(types.updateMySoundError);
export const updateMyTheme = createAction(types.updateMyTheme);
export const updateMyThemeComplete = createAction(
types.updateMyThemeComplete,
checkForSuccessPayload
);
export const updateMyThemeError = createAction(types.updateMyThemeError);
export const updateMyKeyboardShortcuts = createAction(
types.updateMyKeyboardShortcuts
);

View File

@@ -24,7 +24,6 @@ import {
putUpdateMyProfileUI,
putUpdateMyQuincyEmail,
putUpdateMySocials,
putUpdateMyTheme,
putUpdateMyUsername,
putVerifyCert
} from '../../utils/ajax';
@@ -50,8 +49,6 @@ import {
updateMySocialsError,
updateMySoundComplete,
updateMySoundError,
updateMyThemeComplete,
updateMyThemeError,
validateUsernameComplete,
validateUsernameError,
verifyCertComplete,
@@ -132,16 +129,6 @@ function* resetMyEditorLayoutSaga() {
}
}
function* updateMyThemeSaga({ payload: update }) {
try {
const { data } = yield call(putUpdateMyTheme, update);
yield put(updateMyThemeComplete({ ...data, payload: update }));
yield put(createFlashMessage({ ...data }));
} catch (e) {
yield put(updateMyThemeError);
}
}
function* updateMyKeyboardShortcutsSaga({ payload: update }) {
try {
const { data } = yield call(putUpdateMyKeyboardShortcuts, update);
@@ -248,7 +235,6 @@ export function createSettingsSagas(types) {
takeEvery(types.updateMyHonesty, updateMyHonestySaga),
takeEvery(types.updateMySound, updateMySoundSaga),
takeEvery(types.resetMyEditorLayout, resetMyEditorLayoutSaga),
takeEvery(types.updateMyTheme, updateMyThemeSaga),
takeEvery(types.updateMyKeyboardShortcuts, updateMyKeyboardShortcutsSaga),
takeEvery(types.updateMyQuincyEmail, updateMyQuincyEmailSaga),
takeEvery(types.updateMyPortfolio, updateMyPortfolioSaga),

View File

@@ -0,0 +1,45 @@
import { put, takeEvery, select, take } from 'redux-saga/effects';
import { createFlashMessage } from '../components/Flash/redux';
import { setTheme } from './actions';
import { actionTypes } from './action-types';
import { userThemeSelector } from './selectors';
function* toggleThemeSaga() {
const data = { type: 'success', message: 'flash.updated-themes' };
const currentTheme = localStorage.getItem('theme');
const invertedTheme = currentTheme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', invertedTheme);
yield put(setTheme(invertedTheme));
yield put(createFlashMessage({ ...data }));
}
function* initializeThemeSaga() {
// Wait for the fetch userComplete action
yield take(actionTypes.fetchUserComplete);
const userTheme = yield select(userThemeSelector);
const localStorageTheme = localStorage.getItem('theme');
const isSysThemeDark = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
let selectTheme = 'light';
if (localStorageTheme !== null) {
selectTheme = localStorageTheme === 'dark' ? 'dark' : 'light';
} else if (userTheme) {
selectTheme = userTheme === 'night' ? 'dark' : 'light';
} else if (isSysThemeDark) {
selectTheme = 'dark';
}
localStorage.setItem('theme', selectTheme);
yield put(setTheme(selectTheme));
}
export function createThemeSaga(types) {
return [
takeEvery(types.toggleTheme, toggleThemeSaga),
takeEvery(types.initializeTheme, initializeThemeSaga)
];
}

View File

@@ -62,3 +62,13 @@ export interface UpdateCardState {
success: boolean;
error: string;
}
export enum LocalStorageThemes {
Light = 'light',
Dark = 'dark'
}
export enum UserThemes {
Night = 'night',
Default = 'default'
}

View File

@@ -18,12 +18,12 @@ import store from 'store';
import { debounce } from 'lodash-es';
import { useTranslation } from 'react-i18next';
import { Loader } from '../../../components/helpers';
import { Themes } from '../../../components/settings/theme';
import { LocalStorageThemes } from '../../../redux/types';
import { saveChallenge } from '../../../redux/actions';
import {
isDonationModalOpenSelector,
isSignedInSelector,
userSelector
themeSelector
} from '../../../redux/selectors';
import {
ChallengeFiles,
@@ -101,7 +101,7 @@ export interface EditorProps {
stopResetting: () => void;
resetAttempts: () => void;
tests: Test[];
theme: Themes;
theme: LocalStorageThemes;
title: string;
showProjectPreview: boolean;
previewOpen: boolean;
@@ -137,9 +137,9 @@ const mapStateToProps = createSelector(
isProjectPreviewModalOpenSelector,
isResettingSelector,
isSignedInSelector,
userSelector,
challengeTestsSelector,
isChallengeCompletedSelector,
themeSelector,
(
attempts: number,
canFocus: boolean,
@@ -148,9 +148,9 @@ const mapStateToProps = createSelector(
previewOpen: boolean,
isResetting: boolean,
isSignedIn: boolean,
{ theme }: { theme: Themes },
tests: [{ text: string; testString: string; message?: string }],
isChallengeCompleted: boolean
isChallengeCompleted: boolean,
theme: LocalStorageThemes
) => ({
attempts,
canFocus: open ? false : canFocus,
@@ -158,9 +158,9 @@ const mapStateToProps = createSelector(
previewOpen,
isResetting,
isSignedIn,
theme,
tests,
isChallengeCompleted
isChallengeCompleted,
theme
})
);
@@ -1269,9 +1269,9 @@ const Editor = (props: EditorProps): JSX.Element => {
).matches;
const editorSystemTheme = preferDarkScheme ? 'vs-dark-custom' : 'vs-custom';
const editorTheme =
theme === Themes.Night
theme === LocalStorageThemes.Dark
? 'vs-dark-custom'
: theme === Themes.Default
: theme === LocalStorageThemes.Light
? 'vs-custom'
: editorSystemTheme;

View File

@@ -2,10 +2,7 @@ import React, { useRef } from 'react';
import { connect } from 'react-redux';
import { ReflexContainer, ReflexElement, ReflexSplitter } from 'react-reflex';
import { createSelector } from 'reselect';
import {
userSelector,
isDonationModalOpenSelector
} from '../../../redux/selectors';
import { isDonationModalOpenSelector } from '../../../redux/selectors';
import {
canFocusEditorSelector,
consoleOutputSelector,
@@ -14,7 +11,6 @@ import {
import { getTargetEditor } from '../utils/get-target-editor';
import './editor.css';
import { FileKey } from '../../../redux/prop-types';
import { Themes } from '../../../components/settings/theme';
import Editor, { type EditorProps } from './editor';
export type VisibleEditors = {
@@ -51,18 +47,15 @@ const mapStateToProps = createSelector(
canFocusEditorSelector,
consoleOutputSelector,
isDonationModalOpenSelector,
userSelector,
(
visibleEditors: VisibleEditors,
canFocus: boolean,
output: string[],
open,
{ theme }: { theme: Themes }
open
) => ({
visibleEditors,
canFocus: open ? false : canFocus,
output,
theme
output
})
);

View File

@@ -363,12 +363,6 @@ export function putUpdateMySocials(
return put('/update-my-socials', update);
}
export function putUpdateMyTheme(
update: Record<string, string>
): Promise<ResponseWithData<void>> {
return put('/update-my-theme', update);
}
export function putUpdateMyKeyboardShortcuts(
update: Record<string, string>
): Promise<ResponseWithData<void>> {

View File

@@ -1,13 +1,12 @@
import store from 'store';
import { FlashMessages } from '../../components/Flash/redux/flash-messages';
import { Themes } from '../../components/settings/theme';
import { LocalStorageThemes } from '../../redux/types';
const TRY_AGAIN = 'https://campfire-mode.freecodecamp.org/try-again.mp3';
const CHAL_COMP = 'https://campfire-mode.freecodecamp.org/chal-comp.mp3';
const toneUrls = {
[Themes.Default]: 'https://campfire-mode.freecodecamp.org/day.mp3',
[Themes.Night]: 'https://campfire-mode.freecodecamp.org/night.mp3',
[LocalStorageThemes.Light]: 'https://campfire-mode.freecodecamp.org/day.mp3',
[LocalStorageThemes.Dark]: 'https://campfire-mode.freecodecamp.org/night.mp3',
donation: 'https://campfire-mode.freecodecamp.org/donate.mp3',
'tests-completed': CHAL_COMP,
'block-toggle': 'https://tonejs.github.io/audio/berklee/guitar_chord1.mp3',

View File

@@ -79,7 +79,7 @@ test.describe('Editor theme if the system theme is dark', () => {
});
});
test.describe('If the user is signed out', () => {
test.describe('If the user is signed out and has no local storage data', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('should be in dark mode', async ({ page }) => {
@@ -88,6 +88,27 @@ test.describe('Editor theme if the system theme is dark', () => {
await expect(editor).toHaveClass(/vs-dark/);
});
});
test.describe('if the user is signed out and has a dark theme set in local storage', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('should be in dark mode', async ({ page }) => {
// go to the test page
await page.goto(testPage);
// set the dark theme in local storage
await page.evaluate(() => {
localStorage.setItem('theme', 'dark');
});
// reload the page to apply the local storage changes
await page.reload();
// check if the editor is in dark mode
const editor = page.locator("div[role='code'].monaco-editor");
await expect(editor).toHaveClass(/vs-dark/);
});
});
});
test.describe('Editor theme if the system theme is light', () => {
@@ -135,7 +156,7 @@ test.describe('Editor theme if the system theme is light', () => {
});
});
test.describe('If the user is signed out', () => {
test.describe('If the user is signed out and has no local storage value', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('should be in light mode', async ({ page }) => {
@@ -143,5 +164,26 @@ test.describe('Editor theme if the system theme is light', () => {
const editor = page.locator("div[role='code'].monaco-editor");
await expect(editor).toHaveClass(/vs(?!\w)/);
});
test.describe('if the user is signed out and has a light theme set in local storage', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('should be in light mode', async ({ page }) => {
// go to the test page
await page.goto(testPage);
// set the light theme in local storage
await page.evaluate(() => {
localStorage.setItem('theme', 'light');
});
// reload the page to apply the local storage changes
await page.reload();
// check if the editor is in light mode
const editor = page.locator("div[role='code'].monaco-editor");
await expect(editor).toHaveClass(/vs(?!\w)/);
});
});
});
});

View File

@@ -168,15 +168,6 @@ test.describe('Settings - Certified User', () => {
name: translations.buttons['download-data']
});
await expect(downloadButton).toBeVisible();
await expect(
page
.getByRole('group', {
name: translations.settings.labels['night-mode'],
exact: true
})
.locator('p')
).toBeVisible();
await expect(page.locator('#legendsound')).toBeVisible();
await expect(
page.getByText(translations.settings['sound-volume'])