From efcfaf039141d114269655183c65668dc860071a Mon Sep 17 00:00:00 2001 From: Berkeley Martinez Date: Mon, 11 Jul 2016 17:44:50 -0700 Subject: [PATCH] Feature(chat): Add help chat logic --- client/less/chat.less | 1 + client/sagas/gitter-saga.js | 143 ++++++++++++++++-- common/app/redux/actions.js | 7 +- common/app/redux/reducer.js | 15 +- common/app/redux/types.js | 8 +- .../components/classic/Side-Panel.jsx | 16 +- .../components/classic/Tool-Panel.jsx | 10 +- .../components/project/Tool-Panel.jsx | 14 +- common/app/routes/challenges/redux/reducer.js | 2 + 9 files changed, 188 insertions(+), 28 deletions(-) diff --git a/client/less/chat.less b/client/less/chat.less index a6f83171ca..17ec64258d 100644 --- a/client/less/chat.less +++ b/client/less/chat.less @@ -1,3 +1,4 @@ +.chat-embed-help-title, .chat-embed-main-title { display: flex; flex-grow: 1; diff --git a/client/sagas/gitter-saga.js b/client/sagas/gitter-saga.js index 158937fab2..aa8873a6a5 100644 --- a/client/sagas/gitter-saga.js +++ b/client/sagas/gitter-saga.js @@ -1,20 +1,34 @@ -import { Observable } from 'rx'; +import { Subject, Observable } from 'rx'; import Chat from 'gitter-sidecar'; import types from '../../common/app/redux/types'; +import { + openHelpChat, + closeHelpChat, + toggleMainChat +} from '../../common/app/redux/actions'; -function createHeader(document) { +export function createHeader(room, title, document) { + const type = room === 'freecodecamp' ? 'main' : 'help'; const div = document.createElement('div'); const span = document.createElement('span'); const actionBar = document.querySelector( - '#chat-embed-main > .gitter-chat-embed-action-bar' + `#chat-embed-${type}> .gitter-chat-embed-action-bar` ); - span.appendChild(document.createTextNode('Free Code Camp\'s Main Chat')); - div.className = 'chat-embed-main-title'; + span.appendChild(document.createTextNode(title)); + div.className = `chat-embed-${type}-title`; div.appendChild(span); actionBar.insertBefore(div, actionBar.firstChild); } -export default function gitterSaga(actions$, getState, { document }) { +export function createHelpContainer(document) { + const container = document.createElement('aside'); + container.id = 'chat-embed-help'; + container.className = 'gitter-chat-embed is-collapsed'; + document.body.appendChild(container); + return container; +} + +export function createMainChat(getState, document) { let mainChatTitleAdded = false; const mainChatContainer = document.createElement('aside'); mainChatContainer.id = 'chat-embed-main'; @@ -26,7 +40,7 @@ export default function gitterSaga(actions$, getState, { document }) { targetElement: mainChatContainer }); - const mainChatToggle$ = Observable.fromEventPattern( + const toggle$ = Observable.fromEventPattern( h => mainChatContainer.addEventListener('gitter-chat-toggle', h), h => mainChatContainer.removeEventListener('gitter-chat-toggle', h) ) @@ -34,25 +48,128 @@ export default function gitterSaga(actions$, getState, { document }) { const { isMainChatOpen } = getState().app; if (!mainChatTitleAdded) { mainChatTitleAdded = true; - createHeader(document); + createHeader('freecodecamp', 'Free Code Camp\'s Main Chat', document); } if (isMainChatOpen === e.detail.state) { return null; } - return { type: types.toggleMainChat }; + return toggleMainChat(); }); + return { + mainChat, + toggle$ + }; +} + +// only one help room may be alive at once +export function createHelpChat(room, container, proxy, document) { + const title = room.replace(/([A-Z])/g, ' $1'); + let isTitleAdded = false; + const chat = new Chat({ + room: `freecodecamp/${room}`, + activationElement: false, + targetElement: container + }); + // return subscription to toggle stream + // dispose when rooms switch + const subscription = Observable.fromEventPattern( + h => container.addEventListener('gitter-chat-toggle', h), + h => container.removeEventListener('gitter-chat-toggle', h) + ) + .map(e => { + if (!isTitleAdded) { + isTitleAdded = true; + createHeader(room, title, document); + } + const gitterState = e.detail.state; + return gitterState ? openHelpChat() : closeHelpChat(); + }) + // use subject proxy to dispatch actions + .subscribe(proxy); + return { chat, subscription }; +} + +export const cache = {}; +export function toggleHelpChat(isOpen, room, proxy, document) { + // check is container is already created + if (!cache['container']) { + cache['container'] = createHelpContainer(document); + } + const { container } = cache; + if (!cache['chat']) { + const { + chat, + subscription + } = createHelpChat(room, container, proxy, document); + cache.chat = chat; + // make sure we clear out old subscription + if (cache.subscription && cache.subscription.dispose) { + cache.subscription.dispose(); + } + cache.subscription = subscription; + cache.currentRoom = room; + } + // have we switched rooms? + if (!cache.currentRoom === room) { + // room has changed, if chat object exist, destroy it + // and end subscription to toggle + try { + cache.chat.destroy(); + cache.subscription.dispose(); + // chat and subscription may not exist at first so we catch errors here + } catch (err) { + console.error(err); + } + // create new chat room and cache + const { + chat, + subscription + } = createHelpChat(room, container, proxy, document); + cache.chat = chat; + cache.subscription = subscription; + cache.currentRoom = room; + } + // all goes well pull chat object from cache + const { chat } = cache; + chat.toggleChat(isOpen); +} + +export default function gitterSaga(actions$, getState, { document }) { + const helpToggleProxy = new Subject(); + const { + mainChat, + toggle$: mainChatToggle$ + } = createMainChat(getState, document); return Observable.merge( mainChatToggle$, + helpToggleProxy, actions$ .filter(({ type }) => ( type === types.openMainChat || type === types.closeMainChat || - type === types.toggleMainChat + type === types.toggleMainChat || + type === types.toggleHelpChat )) - .map(() => { - const { isMainChatOpen } = getState().app; + .map(({ type }) => { + const state = getState(); + let shouldBlur = false; + if (type === types.toggleHelpChat) { + const { + app: { isHelpChatOpen }, + challengesApp: { helpChatRoom } + } = state; + shouldBlur = !isHelpChatOpen; + toggleHelpChat( + isHelpChatOpen, + helpChatRoom, + helpToggleProxy, + document + ); + } + const { isMainChatOpen } = state.app; mainChat.toggleChat(isMainChatOpen); - if (!isMainChatOpen) { + shouldBlur = !isMainChatOpen; + if (!shouldBlur) { document.activeElement.blur(); } return null; diff --git a/common/app/redux/actions.js b/common/app/redux/actions.js index aa9942a113..75d921a777 100644 --- a/common/app/redux/actions.js +++ b/common/app/redux/actions.js @@ -54,8 +54,9 @@ export const createErrorObservable = error => Observable.just({ // drawers export const toggleMapDrawer = createAction(types.toggleMapDrawer); -export const toggleWikiDrawer = createAction(types.toggleWikiDrawer); - -// chat export const toggleMainChat = createAction(types.toggleMainChat); +export const toggleHelpChat = createAction(types.toggleHelpChat); +export const openHelpChat = createAction(types.openHelpChat); +export const closeHelpChat = createAction(types.closeHelpChat); + export const toggleNightMode = createAction(types.toggleNightMode); diff --git a/common/app/redux/reducer.js b/common/app/redux/reducer.js index 48b1a95226..0b9064644b 100644 --- a/common/app/redux/reducer.js +++ b/common/app/redux/reducer.js @@ -9,7 +9,8 @@ const initialState = { csrfToken: '', windowHeight: 0, navHeight: 0, - isMainChatOpen: false + isMainChatOpen: false, + isHelpChatOpen: false }; export default handleActions( @@ -50,6 +51,18 @@ export default handleActions( ...state, isMainChatOpen: !state.isMainChatOpen }), + [types.toggleHelpChat]: state => ({ + ...state, + isHelpChatOpen: !state.isHelpChatOpen + }), + [types.openHelpChat]: state => ({ + ...state, + isHelpChatOpen: true + }), + [types.closeHelpChat]: state => ({ + ...state, + isHelpChatOpen: false + }), [types.delayedRedirect]: (state, { payload }) => ({ ...state, delayedRedirect: payload diff --git a/common/app/redux/types.js b/common/app/redux/types.js index ab0864c0c7..db6d159809 100644 --- a/common/app/redux/types.js +++ b/common/app/redux/types.js @@ -29,8 +29,12 @@ export default createTypes([ 'toggleMapDrawer', 'toggleWikiDrawer', - // main chat + // chat 'openMainChat', 'closeMainChat', - 'toggleMainChat' + 'toggleMainChat', + + 'openHelpChat', + 'closeHelpChat', + 'toggleHelpChat' ], 'app'); diff --git a/common/app/routes/challenges/components/classic/Side-Panel.jsx b/common/app/routes/challenges/components/classic/Side-Panel.jsx index a6b0f4f5fc..e6cf6f7f99 100644 --- a/common/app/routes/challenges/components/classic/Side-Panel.jsx +++ b/common/app/routes/challenges/components/classic/Side-Panel.jsx @@ -2,7 +2,6 @@ import React, { PropTypes } from 'react'; import ReactDom from 'react-dom'; import { createSelector } from 'reselect'; import { connect } from 'react-redux'; - import PureComponent from 'react-pure-render/component'; import { Col, Row } from 'react-bootstrap'; @@ -12,8 +11,14 @@ import ToolPanel from './Tool-Panel.jsx'; import { challengeSelector } from '../../redux/selectors'; import { updateHint, executeChallenge } from '../../redux/actions'; import { makeToast } from '../../../../toasts/redux/actions'; +import { toggleHelpChat } from '../../../../redux/actions'; -const bindableActions = { makeToast, executeChallenge, updateHint }; +const bindableActions = { + makeToast, + executeChallenge, + updateHint, + toggleHelpChat +}; const mapStateToProps = createSelector( challengeSelector, state => state.app.windowHeight, @@ -53,7 +58,8 @@ export class SidePanel extends PureComponent { output: PropTypes.string, hints: PropTypes.string, updateHint: PropTypes.func, - makeToast: PropTypes.func + makeToast: PropTypes.func, + toggleHelpChat: PropTypes.func }; renderDescription(description = [ 'Happy Coding!' ], descriptionRegex) { @@ -92,7 +98,8 @@ export class SidePanel extends PureComponent { hint, executeChallenge, updateHint, - makeToast + makeToast, + toggleHelpChat } = this.props; const style = { overflowX: 'hidden', @@ -124,6 +131,7 @@ export class SidePanel extends PureComponent { executeChallenge={ executeChallenge } hint={ hint } makeToast={ makeToast } + toggleHelpChat={ toggleHelpChat } updateHint={ updateHint } /> diff --git a/common/app/routes/challenges/components/classic/Tool-Panel.jsx b/common/app/routes/challenges/components/classic/Tool-Panel.jsx index 27883ba235..7dc3119a55 100644 --- a/common/app/routes/challenges/components/classic/Tool-Panel.jsx +++ b/common/app/routes/challenges/components/classic/Tool-Panel.jsx @@ -13,7 +13,8 @@ export default class ToolPanel extends PureComponent { static propTypes = { executeChallenge: PropTypes.func, updateHint: PropTypes.func, - hint: PropTypes.string + hint: PropTypes.string, + toggleHelpChat: PropTypes.func }; makeHint() { @@ -50,7 +51,11 @@ export default class ToolPanel extends PureComponent { } render() { - const { hint, executeChallenge } = this.props; + const { + hint, + executeChallenge, + toggleHelpChat + } = this.props; return (
{ this.renderHint(hint, this.makeHint) } @@ -79,6 +84,7 @@ export default class ToolPanel extends PureComponent { bsSize='large' bsStyle='primary' componentClass='label' + onClick={ toggleHelpChat } > Help diff --git a/common/app/routes/challenges/components/project/Tool-Panel.jsx b/common/app/routes/challenges/components/project/Tool-Panel.jsx index 1a1d38db03..3fceff48eb 100644 --- a/common/app/routes/challenges/components/project/Tool-Panel.jsx +++ b/common/app/routes/challenges/components/project/Tool-Panel.jsx @@ -15,7 +15,12 @@ import { simpleProject, frontEndProject } from '../../../../utils/challengeTypes'; +import { toggleHelpChat } from '../../../../redux/actions'; +const bindableActions = { + submitChallenge, + toggleHelpChat +}; const mapStateToProps = createSelector( challengeSelector, state => state.app.isSignedIn, @@ -37,7 +42,8 @@ export class ToolPanel extends PureComponent { isSignedIn: PropTypes.bool, isSimple: PropTypes.bool, isFrontEnd: PropTypes.bool, - isSubmitting: PropTypes.bool + isSubmitting: PropTypes.bool, + toggleHelpChat: PropTypes.func }; renderSubmitButton(isSignedIn, submitChallenge) { @@ -62,7 +68,8 @@ export class ToolPanel extends PureComponent { isSimple, isSignedIn, isSubmitting, - submitChallenge + submitChallenge, + toggleHelpChat } = this.props; const FormElement = isFrontEnd ? FrontEndForm : BackEndForm; @@ -79,6 +86,7 @@ export class ToolPanel extends PureComponent { bsStyle='primary' className='btn-primary-ghost btn-big' componentClass='div' + onClick={ toggleHelpChat } > Help @@ -97,5 +105,5 @@ export class ToolPanel extends PureComponent { export default connect( mapStateToProps, - { submitChallenge } + bindableActions )(ToolPanel); diff --git a/common/app/routes/challenges/redux/reducer.js b/common/app/routes/challenges/redux/reducer.js index 073408ad96..930e74ae78 100644 --- a/common/app/routes/challenges/redux/reducer.js +++ b/common/app/routes/challenges/redux/reducer.js @@ -42,6 +42,7 @@ const initialUiState = { const initialState = { id: '', challenge: '', + helpChatRoom: 'Help', // old code storage key legacyKey: '', files: {}, @@ -68,6 +69,7 @@ const mainReducer = handleActions( challenge: challenge.dashedName, key: getFileKey(challenge), tests: createTests(challenge), + helpChatRoom: challenge.helpRoom || 'Help', numOfHints: Array.isArray(challenge.hints) ? challenge.hints.length : 0 }), [types.updateTests]: (state, { payload: tests }) => ({