diff --git a/client/src/client-only-routes/show-certification.tsx b/client/src/client-only-routes/show-certification.tsx index 70c98a60881..ae3721473f3 100644 --- a/client/src/client-only-routes/show-certification.tsx +++ b/client/src/client-only-routes/show-certification.tsx @@ -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' > & { +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 })} diff --git a/client/src/components/Donation/donate-form.tsx b/client/src/components/Donation/donate-form.tsx index a426dfb58d4..2166fad829d 100644 --- a/client/src/components/Donation/donate-form.tsx +++ b/client/src/components/Donation/donate-form.tsx @@ -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 }) ); diff --git a/client/src/components/Donation/multi-tier-donation-form.tsx b/client/src/components/Donation/multi-tier-donation-form.tsx index 8b6c9556c9a..6087f9a7eb5 100644 --- a/client/src/components/Donation/multi-tier-donation-form.tsx +++ b/client/src/components/Donation/multi-tier-donation-form.tsx @@ -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({ diff --git a/client/src/components/Donation/paypal-button.tsx b/client/src/components/Donation/paypal-button.tsx index 684abd0b190..9c881ddaa52 100644 --- a/client/src/components/Donation/paypal-button.tsx +++ b/client/src/components/Donation/paypal-button.tsx @@ -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; - theme: Themes; + theme: LocalStorageThemes; isSubscription?: boolean; handlePaymentButtonLoad: (provider: 'stripe' | 'paypal') => void; isMinimalForm: boolean | undefined; @@ -91,7 +91,7 @@ class PaypalButton extends Component { 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; } diff --git a/client/src/components/Donation/stripe-card-form.tsx b/client/src/components/Donation/stripe-card-form.tsx index a67ae832bc4..2f9235c019f 100644 --- a/client/src/components/Donation/stripe-card-form.tsx +++ b/client/src/components/Donation/stripe-card-form.tsx @@ -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` } diff --git a/client/src/components/Donation/wallets-button.tsx b/client/src/components/Donation/wallets-button.tsx index 311dea53383..038ce8a2a8f 100644 --- a/client/src/components/Donation/wallets-button.tsx +++ b/client/src/components/Donation/wallets-button.tsx @@ -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' } }, diff --git a/client/src/components/Flash/redux/index.ts b/client/src/components/Flash/redux/index.ts index ab543564c6d..4ad02f4f5ab 100644 --- a/client/src/components/Flash/redux/index.ts +++ b/client/src/components/Flash/redux/index.ts @@ -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 => { // 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); } diff --git a/client/src/components/Header/components/nav-links.tsx b/client/src/components/Header/components/nav-links.tsx index 83d730ea9d5..5a255f19d46 100644 --- a/client/src/components/Header/components/nav-links.tsx +++ b/client/src/components/Header/components/nav-links.tsx @@ -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 { +export interface NavLinksProps { displayMenu: boolean; showMenu: () => void; hideMenu: () => void; user?: User; menuButtonRef: React.RefObject; 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) => 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({
  • @@ -304,4 +276,7 @@ function NavLinks({ NavLinks.displayName = 'NavLinks'; -export default connect(null, mapDispatchToProps)(withTranslation()(NavLinks)); +export default connect( + mapStateToProps, + mapDispatchToProps +)(withTranslation()(NavLinks)); diff --git a/client/src/components/Header/components/universal-nav.tsx b/client/src/components/Header/components/universal-nav.tsx index 8ffcdaf487e..6273cdb8a8b 100644 --- a/client/src/components/Header/components/universal-nav.tsx +++ b/client/src/components/Header/components/universal-nav.tsx @@ -19,7 +19,7 @@ const SearchBarOptimized = Loadable( type UniversalNavProps = Omit< NavLinksProps, - 'toggleNightMode' | 'openSignoutModal' + 'toggleTheme' | 'openSignoutModal' > & { fetchState: { pending: boolean }; searchBarRef?: React.RefObject; diff --git a/client/src/components/layouts/default.tsx b/client/src/components/layouts/default.tsx index e2e6a6fc1ce..8058dfbfa21 100644 --- a/client/src/components/layouts/default.tsx +++ b/client/src/components/layouts/default.tsx @@ -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' && } 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 = ({ <> - { - toggleNightMode( - currentTheme === Themes.Night ? Themes.Default : Themes.Night - ); - }} - /> - ); -} - -ThemeSettings.displayName = 'ThemeSettings'; diff --git a/client/src/redux/action-types.js b/client/src/redux/action-types.js index f5022221e3c..f7b36b41d84 100644 --- a/client/src/redux/action-types.js +++ b/client/src/redux/action-types.js @@ -4,6 +4,9 @@ export const ns = 'app'; export const actionTypes = createTypes( [ + 'setTheme', + 'initializeTheme', + 'toggleTheme', 'appMount', 'hardGoTo', 'allowBlockDonationRequests', diff --git a/client/src/redux/actions.js b/client/src/redux/actions.js index bc369ee5922..9c70e4a5a42 100644 --- a/client/src/redux/actions.js +++ b/client/src/redux/actions.js @@ -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 ); diff --git a/client/src/redux/index.js b/client/src/redux/index.js index a4b76b21ab9..703027d8c4f 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -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 }) => diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts index 489c2567fb6..3dad46649d4 100644 --- a/client/src/redux/prop-types.ts +++ b/client/src/redux/prop-types.ts @@ -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; diff --git a/client/src/redux/selectors.js b/client/src/redux/selectors.js index af80a85557c..ca564626265 100644 --- a/client/src/redux/selectors.js +++ b/client/src/redux/selectors.js @@ -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); diff --git a/client/src/redux/settings/action-types.js b/client/src/redux/settings/action-types.js index 68931381c01..8bb0ddde85b 100644 --- a/client/src/redux/settings/action-types.js +++ b/client/src/redux/settings/action-types.js @@ -10,7 +10,6 @@ export const actionTypes = createTypes( ...createAsyncTypes('updateMyEmail'), ...createAsyncTypes('updateMySocials'), ...createAsyncTypes('updateMySound'), - ...createAsyncTypes('updateMyTheme'), ...createAsyncTypes('updateMyKeyboardShortcuts'), ...createAsyncTypes('updateMyHonesty'), ...createAsyncTypes('updateMyQuincyEmail'), diff --git a/client/src/redux/settings/actions.js b/client/src/redux/settings/actions.js index 466e74dfa22..4a561ff4650 100644 --- a/client/src/redux/settings/actions.js +++ b/client/src/redux/settings/actions.js @@ -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 ); diff --git a/client/src/redux/settings/settings-sagas.js b/client/src/redux/settings/settings-sagas.js index 35c47eef3fb..f0cdb459948 100644 --- a/client/src/redux/settings/settings-sagas.js +++ b/client/src/redux/settings/settings-sagas.js @@ -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), diff --git a/client/src/redux/theme-saga.js b/client/src/redux/theme-saga.js new file mode 100644 index 00000000000..5281ca91eec --- /dev/null +++ b/client/src/redux/theme-saga.js @@ -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) + ]; +} diff --git a/client/src/redux/types.ts b/client/src/redux/types.ts index 67475a3c4ec..1e9f935aade 100644 --- a/client/src/redux/types.ts +++ b/client/src/redux/types.ts @@ -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' +} diff --git a/client/src/templates/Challenges/classic/editor.tsx b/client/src/templates/Challenges/classic/editor.tsx index b8663cd6ed8..ed00998f34a 100644 --- a/client/src/templates/Challenges/classic/editor.tsx +++ b/client/src/templates/Challenges/classic/editor.tsx @@ -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; diff --git a/client/src/templates/Challenges/classic/multifile-editor.tsx b/client/src/templates/Challenges/classic/multifile-editor.tsx index 86c60e044d6..45cf004426b 100644 --- a/client/src/templates/Challenges/classic/multifile-editor.tsx +++ b/client/src/templates/Challenges/classic/multifile-editor.tsx @@ -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 }) ); diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts index 6e39591d7bc..961880c6891 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -363,12 +363,6 @@ export function putUpdateMySocials( return put('/update-my-socials', update); } -export function putUpdateMyTheme( - update: Record -): Promise> { - return put('/update-my-theme', update); -} - export function putUpdateMyKeyboardShortcuts( update: Record ): Promise> { diff --git a/client/src/utils/tone/index.ts b/client/src/utils/tone/index.ts index d0d0caaa87c..97dcbd48d20 100644 --- a/client/src/utils/tone/index.ts +++ b/client/src/utils/tone/index.ts @@ -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', diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index f86f3a8e907..971f2bcaf96 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -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)/); + }); + }); }); }); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 6d515552958..612f898a0ad 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -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'])