diff --git a/client/sagas/index.js b/client/sagas/index.js index ec4074af58..13e755f9fa 100644 --- a/client/sagas/index.js +++ b/client/sagas/index.js @@ -8,6 +8,7 @@ import codeStorageSaga from './code-storage-saga'; import gitterSaga from './gitter-saga'; import mouseTrapSaga from './mouse-trap-saga'; import analyticsSaga from './analytics-saga'; +import nightModeSaga from './night-mode-saga'; export default [ errSaga, @@ -19,5 +20,6 @@ export default [ codeStorageSaga, gitterSaga, mouseTrapSaga, - analyticsSaga + analyticsSaga, + nightModeSaga ]; diff --git a/client/sagas/night-mode-saga.js b/client/sagas/night-mode-saga.js new file mode 100644 index 0000000000..efc5e9d5c9 --- /dev/null +++ b/client/sagas/night-mode-saga.js @@ -0,0 +1,47 @@ +import { Observable } from 'rx'; +import { postJSON$ } from '../../common/utils/ajax-stream'; +import types from '../../common/app/redux/types'; +import { + addThemeToBody, + updateTheme, + createErrorObservable +} from '../../common/app/redux/actions'; + +export default function nightModeSaga( + actions, + getState, + { document: { body } } +) { + const toggleBodyClass = actions + .filter(({ type }) => types.addThemeToBody === type) + .doOnNext(({ payload: theme }) => { + if (theme === 'night') { + body.classList.add('night'); + } else { + body.classList.remove('night'); + } + }) + .filter(() => false); + const toggle = actions + .filter(({ type }) => types.toggleNightMode === type); + + const optimistic = toggle + .flatMap(() => { + const { app: { theme } } = getState(); + const newTheme = !theme || theme === 'default' ? 'night' : 'default'; + return Observable.of( + updateTheme(newTheme), + addThemeToBody(newTheme) + ); + }); + + const ajax = toggle + .debounce(250) + .flatMapLatest(() => { + const { app: { theme, csrfToken: _csrf } } = getState(); + return postJSON$('/update-my-theme', { _csrf, theme }) + .catch(createErrorObservable); + }); + + return Observable.merge(optimistic, toggleBodyClass, ajax); +} diff --git a/common/app/redux/actions.js b/common/app/redux/actions.js index 71d6c50e02..83fefaf255 100644 --- a/common/app/redux/actions.js +++ b/common/app/redux/actions.js @@ -188,4 +188,13 @@ export const closeHelpChat = createAction( }) ); -export const toggleNightMode = createAction(types.toggleNightMode); +export const toggleNightMode = createAction( + types.toggleNightMode, + // we use this function to avoid hanging onto the eventObject + // so that react can recycle it + () => null +); +// updateTheme(theme: /night|default/) => Action +export const updateTheme = createAction(types.updateTheme); +// addThemeToBody(theme: /night|default/) => Action +export const addThemeToBody = createAction(types.addThemeToBody); diff --git a/common/app/redux/fetch-user-saga.js b/common/app/redux/fetch-user-saga.js index ba2f6cbc4e..5afa9b5872 100644 --- a/common/app/redux/fetch-user-saga.js +++ b/common/app/redux/fetch-user-saga.js @@ -5,11 +5,12 @@ import { updateThisUser, updateCompletedChallenges, createErrorObservable, - showSignIn + showSignIn, + updateTheme, + addThemeToBody } from './actions'; const { fetchUser } = types; - export default function getUserSaga(action$, getState, { services }) { return action$ .filter(action => action.type === fetchUser) @@ -19,10 +20,14 @@ export default function getUserSaga(action$, getState, { services }) { if (!entities || !result) { return Observable.just(showSignIn()); } + const user = entities.user[result]; + const isNightMode = user.theme === 'night'; return Observable.of( addUser(entities), + updateCompletedChallenges(result), updateThisUser(result), - updateCompletedChallenges(result) + isNightMode ? updateTheme(user.theme) : null, + isNightMode ? addThemeToBody(user.theme) : null ); }) .catch(createErrorObservable); diff --git a/common/app/redux/reducer.js b/common/app/redux/reducer.js index b73b20cccf..4ccf742824 100644 --- a/common/app/redux/reducer.js +++ b/common/app/redux/reducer.js @@ -10,7 +10,8 @@ const initialState = { windowHeight: 0, navHeight: 0, isMainChatOpen: false, - isHelpChatOpen: false + isHelpChatOpen: false, + theme: 'default' }; export default handleActions( @@ -29,6 +30,10 @@ export default handleActions( ...state, lang: payload }), + [types.updateTheme]: (state, { payload = 'default' }) => ({ + ...state, + theme: payload + }), [types.showSignIn]: state => ({ ...state, shouldShowSignIn: true diff --git a/common/app/redux/types.js b/common/app/redux/types.js index d0ec7220cb..1da89d89c3 100644 --- a/common/app/redux/types.js +++ b/common/app/redux/types.js @@ -18,7 +18,6 @@ export default createTypes([ 'updateMyCurrentChallenge', 'handleError', - 'toggleNightMode', // used to hit the server 'hardGoTo', 'delayedRedirect', @@ -44,5 +43,10 @@ export default createTypes([ 'openHelpChat', 'closeHelpChat', - 'toggleHelpChat' + 'toggleHelpChat', + + // night mode + 'toggleNightMode', + 'updateTheme', + 'addThemeToBody' ], 'app'); diff --git a/common/models/user.js b/common/models/user.js index ee3bfb5285..3134479076 100644 --- a/common/models/user.js +++ b/common/models/user.js @@ -599,6 +599,7 @@ module.exports = function(User) { .toPromise(); }; + // deprecated. remove once live User.remoteMethod( 'updateTheme', { diff --git a/server/boot/settings.js b/server/boot/settings.js index 496a2df249..9cc0900407 100644 --- a/server/boot/settings.js +++ b/server/boot/settings.js @@ -64,8 +64,26 @@ export default function settingsController(app) { ); } + function updateMyTheme(req, res, next) { + req.checkBody('theme', 'Theme is invalid.').isLength({ min: 4 }); + const { body: { theme } } = req; + const errors = req.validationErrors(true); + if (errors) { + return res.status(403).json({ errors }); + } + if (req.user.theme === theme) { + return res.json({ msg: 'Theme already set' }); + } + return req.user.updateTheme('' + theme) + .then( + data => res.json(data), + next + ); + } + api.post( '/toggle-lockdown', + ifNoUser401, toggleUserFlag('isLocked') ); api.post( @@ -99,5 +117,12 @@ export default function settingsController(app) { ifNoUser401, updateMyCurrentChallenge ); + + api.post( + '/update-my-theme', + ifNoUser401, + updateMyTheme + ); + app.use(api); }