Feature(chat): Add help chat logic

This commit is contained in:
Berkeley Martinez
2016-07-11 17:44:50 -07:00
parent d918f02906
commit efcfaf0391
9 changed files with 188 additions and 28 deletions

View File

@ -1,3 +1,4 @@
.chat-embed-help-title,
.chat-embed-main-title { .chat-embed-main-title {
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;

View File

@ -1,20 +1,34 @@
import { Observable } from 'rx'; import { Subject, Observable } from 'rx';
import Chat from 'gitter-sidecar'; import Chat from 'gitter-sidecar';
import types from '../../common/app/redux/types'; 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 div = document.createElement('div');
const span = document.createElement('span'); const span = document.createElement('span');
const actionBar = document.querySelector( 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')); span.appendChild(document.createTextNode(title));
div.className = 'chat-embed-main-title'; div.className = `chat-embed-${type}-title`;
div.appendChild(span); div.appendChild(span);
actionBar.insertBefore(div, actionBar.firstChild); 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; let mainChatTitleAdded = false;
const mainChatContainer = document.createElement('aside'); const mainChatContainer = document.createElement('aside');
mainChatContainer.id = 'chat-embed-main'; mainChatContainer.id = 'chat-embed-main';
@ -26,7 +40,7 @@ export default function gitterSaga(actions$, getState, { document }) {
targetElement: mainChatContainer targetElement: mainChatContainer
}); });
const mainChatToggle$ = Observable.fromEventPattern( const toggle$ = Observable.fromEventPattern(
h => mainChatContainer.addEventListener('gitter-chat-toggle', h), h => mainChatContainer.addEventListener('gitter-chat-toggle', h),
h => mainChatContainer.removeEventListener('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; const { isMainChatOpen } = getState().app;
if (!mainChatTitleAdded) { if (!mainChatTitleAdded) {
mainChatTitleAdded = true; mainChatTitleAdded = true;
createHeader(document); createHeader('freecodecamp', 'Free Code Camp\'s Main Chat', document);
} }
if (isMainChatOpen === e.detail.state) { if (isMainChatOpen === e.detail.state) {
return null; 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( return Observable.merge(
mainChatToggle$, mainChatToggle$,
helpToggleProxy,
actions$ actions$
.filter(({ type }) => ( .filter(({ type }) => (
type === types.openMainChat || type === types.openMainChat ||
type === types.closeMainChat || type === types.closeMainChat ||
type === types.toggleMainChat type === types.toggleMainChat ||
type === types.toggleHelpChat
)) ))
.map(() => { .map(({ type }) => {
const { isMainChatOpen } = getState().app; 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); mainChat.toggleChat(isMainChatOpen);
if (!isMainChatOpen) { shouldBlur = !isMainChatOpen;
if (!shouldBlur) {
document.activeElement.blur(); document.activeElement.blur();
} }
return null; return null;

View File

@ -54,8 +54,9 @@ export const createErrorObservable = error => Observable.just({
// drawers // drawers
export const toggleMapDrawer = createAction(types.toggleMapDrawer); export const toggleMapDrawer = createAction(types.toggleMapDrawer);
export const toggleWikiDrawer = createAction(types.toggleWikiDrawer);
// chat
export const toggleMainChat = createAction(types.toggleMainChat); 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); export const toggleNightMode = createAction(types.toggleNightMode);

View File

@ -9,7 +9,8 @@ const initialState = {
csrfToken: '', csrfToken: '',
windowHeight: 0, windowHeight: 0,
navHeight: 0, navHeight: 0,
isMainChatOpen: false isMainChatOpen: false,
isHelpChatOpen: false
}; };
export default handleActions( export default handleActions(
@ -50,6 +51,18 @@ export default handleActions(
...state, ...state,
isMainChatOpen: !state.isMainChatOpen 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 }) => ({ [types.delayedRedirect]: (state, { payload }) => ({
...state, ...state,
delayedRedirect: payload delayedRedirect: payload

View File

@ -29,8 +29,12 @@ export default createTypes([
'toggleMapDrawer', 'toggleMapDrawer',
'toggleWikiDrawer', 'toggleWikiDrawer',
// main chat // chat
'openMainChat', 'openMainChat',
'closeMainChat', 'closeMainChat',
'toggleMainChat' 'toggleMainChat',
'openHelpChat',
'closeHelpChat',
'toggleHelpChat'
], 'app'); ], 'app');

View File

@ -2,7 +2,6 @@ import React, { PropTypes } from 'react';
import ReactDom from 'react-dom'; import ReactDom from 'react-dom';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PureComponent from 'react-pure-render/component'; import PureComponent from 'react-pure-render/component';
import { Col, Row } from 'react-bootstrap'; import { Col, Row } from 'react-bootstrap';
@ -12,8 +11,14 @@ import ToolPanel from './Tool-Panel.jsx';
import { challengeSelector } from '../../redux/selectors'; import { challengeSelector } from '../../redux/selectors';
import { updateHint, executeChallenge } from '../../redux/actions'; import { updateHint, executeChallenge } from '../../redux/actions';
import { makeToast } from '../../../../toasts/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( const mapStateToProps = createSelector(
challengeSelector, challengeSelector,
state => state.app.windowHeight, state => state.app.windowHeight,
@ -53,7 +58,8 @@ export class SidePanel extends PureComponent {
output: PropTypes.string, output: PropTypes.string,
hints: PropTypes.string, hints: PropTypes.string,
updateHint: PropTypes.func, updateHint: PropTypes.func,
makeToast: PropTypes.func makeToast: PropTypes.func,
toggleHelpChat: PropTypes.func
}; };
renderDescription(description = [ 'Happy Coding!' ], descriptionRegex) { renderDescription(description = [ 'Happy Coding!' ], descriptionRegex) {
@ -92,7 +98,8 @@ export class SidePanel extends PureComponent {
hint, hint,
executeChallenge, executeChallenge,
updateHint, updateHint,
makeToast makeToast,
toggleHelpChat
} = this.props; } = this.props;
const style = { const style = {
overflowX: 'hidden', overflowX: 'hidden',
@ -124,6 +131,7 @@ export class SidePanel extends PureComponent {
executeChallenge={ executeChallenge } executeChallenge={ executeChallenge }
hint={ hint } hint={ hint }
makeToast={ makeToast } makeToast={ makeToast }
toggleHelpChat={ toggleHelpChat }
updateHint={ updateHint } updateHint={ updateHint }
/> />
<Output output={ output }/> <Output output={ output }/>

View File

@ -13,7 +13,8 @@ export default class ToolPanel extends PureComponent {
static propTypes = { static propTypes = {
executeChallenge: PropTypes.func, executeChallenge: PropTypes.func,
updateHint: PropTypes.func, updateHint: PropTypes.func,
hint: PropTypes.string hint: PropTypes.string,
toggleHelpChat: PropTypes.func
}; };
makeHint() { makeHint() {
@ -50,7 +51,11 @@ export default class ToolPanel extends PureComponent {
} }
render() { render() {
const { hint, executeChallenge } = this.props; const {
hint,
executeChallenge,
toggleHelpChat
} = this.props;
return ( return (
<div> <div>
{ this.renderHint(hint, this.makeHint) } { this.renderHint(hint, this.makeHint) }
@ -79,6 +84,7 @@ export default class ToolPanel extends PureComponent {
bsSize='large' bsSize='large'
bsStyle='primary' bsStyle='primary'
componentClass='label' componentClass='label'
onClick={ toggleHelpChat }
> >
Help Help
</Button> </Button>

View File

@ -15,7 +15,12 @@ import {
simpleProject, simpleProject,
frontEndProject frontEndProject
} from '../../../../utils/challengeTypes'; } from '../../../../utils/challengeTypes';
import { toggleHelpChat } from '../../../../redux/actions';
const bindableActions = {
submitChallenge,
toggleHelpChat
};
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
challengeSelector, challengeSelector,
state => state.app.isSignedIn, state => state.app.isSignedIn,
@ -37,7 +42,8 @@ export class ToolPanel extends PureComponent {
isSignedIn: PropTypes.bool, isSignedIn: PropTypes.bool,
isSimple: PropTypes.bool, isSimple: PropTypes.bool,
isFrontEnd: PropTypes.bool, isFrontEnd: PropTypes.bool,
isSubmitting: PropTypes.bool isSubmitting: PropTypes.bool,
toggleHelpChat: PropTypes.func
}; };
renderSubmitButton(isSignedIn, submitChallenge) { renderSubmitButton(isSignedIn, submitChallenge) {
@ -62,7 +68,8 @@ export class ToolPanel extends PureComponent {
isSimple, isSimple,
isSignedIn, isSignedIn,
isSubmitting, isSubmitting,
submitChallenge submitChallenge,
toggleHelpChat
} = this.props; } = this.props;
const FormElement = isFrontEnd ? FrontEndForm : BackEndForm; const FormElement = isFrontEnd ? FrontEndForm : BackEndForm;
@ -79,6 +86,7 @@ export class ToolPanel extends PureComponent {
bsStyle='primary' bsStyle='primary'
className='btn-primary-ghost btn-big' className='btn-primary-ghost btn-big'
componentClass='div' componentClass='div'
onClick={ toggleHelpChat }
> >
Help Help
</Button> </Button>
@ -97,5 +105,5 @@ export class ToolPanel extends PureComponent {
export default connect( export default connect(
mapStateToProps, mapStateToProps,
{ submitChallenge } bindableActions
)(ToolPanel); )(ToolPanel);

View File

@ -42,6 +42,7 @@ const initialUiState = {
const initialState = { const initialState = {
id: '', id: '',
challenge: '', challenge: '',
helpChatRoom: 'Help',
// old code storage key // old code storage key
legacyKey: '', legacyKey: '',
files: {}, files: {},
@ -68,6 +69,7 @@ const mainReducer = handleActions(
challenge: challenge.dashedName, challenge: challenge.dashedName,
key: getFileKey(challenge), key: getFileKey(challenge),
tests: createTests(challenge), tests: createTests(challenge),
helpChatRoom: challenge.helpRoom || 'Help',
numOfHints: Array.isArray(challenge.hints) ? challenge.hints.length : 0 numOfHints: Array.isArray(challenge.hints) ? challenge.hints.length : 0
}), }),
[types.updateTests]: (state, { payload: tests }) => ({ [types.updateTests]: (state, { payload: tests }) => ({