├── .babelrc ├── .gitignore ├── README.md ├── package.json ├── public ├── css │ └── main.css └── index.html ├── server ├── db │ ├── Channel.js │ ├── User.js │ ├── index.js │ └── initializeDB.js ├── getDefaultState.js ├── server.js ├── serverRenderMiddleWare.js └── simulateActivity.js ├── src ├── App.jsx ├── App.less ├── actions │ ├── completeChannelCreation.js │ ├── index.js │ ├── openContactChannel.js │ ├── receiveMessage.js │ ├── requestCreateChannel.js │ ├── setActiveChannel.js │ ├── setChannelInfo.js │ ├── setUserInfo.js │ ├── submitChannelInputText.js │ ├── updateChannelInputText.js │ ├── updateChannelLoadedStatus.js │ ├── updateStatus.js │ └── updateUserFetchStatus.js ├── combineReducers.js ├── components │ ├── ChannelContent │ │ ├── ChannelContent.js │ │ └── ChannelContentContainer.js │ ├── ChannelList │ │ ├── ChannelList.js │ │ ├── ChannelListContainer.js │ │ └── ChannelListItem.js │ ├── ContactList │ │ ├── ContactList.js │ │ ├── ContactListContainer.jsx │ │ ├── ContactListItem.js │ │ └── ContactListItemContainer.js │ ├── CurrentChannelTextInput │ │ ├── CurrentChannelTextInput.js │ │ └── CurrentChannelTextInputContainer.js │ ├── CurrentUser │ │ ├── CurrentUser.js │ │ └── CurrentUserContainer.js │ ├── DevTools │ │ └── DevTools.jsx │ ├── Message │ │ ├── Message.js │ │ └── MessageContainer.js │ └── index.js ├── getPreloadedState.js ├── getStore.js ├── initSagas.js ├── main.jsx ├── reducers │ ├── activeChannel.js │ ├── channels.js │ ├── currentUser.js │ ├── index.js │ └── userInfo.js ├── sagas │ ├── activeChannelSaga.js │ ├── channelSaga.js │ ├── createChannelSaga.js │ ├── currentChannelInputSaga.js │ ├── currentUserStatusSaga.js │ ├── index.js │ └── userInfoSaga.js ├── selectors │ ├── activeChannelSelector.js │ ├── channelSelector.js │ ├── currentUser.js │ ├── index.js │ └── userSelector.js ├── socketMiddleware.js └── utility │ ├── chance.js │ ├── createReducer.js │ ├── getDebugSessionKey.js │ ├── index.js │ └── makeActionCreator.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015","react"], 3 | "plugins": ["transform-regenerator","transform-object-rest-spread"] 4 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | .idea -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Advanced Redux Messenger 2 | ## Redux Messenger 3 | ![2017-03-28 08_00_27-redux messaging app](https://cloud.githubusercontent.com/assets/4268152/24403858/c707c6aa-138c-11e7-93fc-c9a565001bc2.png) 4 | ### Introduction 5 | This application is a fully functional messenger application similar to Slack or HipChat. It includes the following features, 6 | - State managed with Redux 7 | - React-Redux view 8 | - Selectors with Reselect 9 | - Immutable State 10 | - Live server updates with websockets 11 | - Redux-Saga 12 | - More! 13 | ### How to Use This Application 14 | - This application is meant as a reference for students completing Advanced Redux on Pluralsight 15 | - Developers who are already familiar with the technology are welcome to copy the application and make any desired changes 16 | 17 | ### Getting started 18 | 1. Clone the application, 19 | ```bash 20 | git clone git@github.com:danielstern/advanced-redux.git 21 | ``` 22 | 2. Install dependencies 23 | ```bash 24 | cd advanced-redux 25 | npm install 26 | npm install -g babel-cli 27 | ``` 28 | 29 | 3. Start the application 30 | ```javascript 31 | npm start 32 | ``` 33 | or 34 | ```javascript 35 | npm run dev 36 | ``` 37 | * `npm run dev` is meant for development and includes file monitoring with Nodemon 38 | 39 | 4. Navigate to the application in Chrome 40 | [http://localhost:9000/#](http://localhost:9000) 41 | 42 | ### Troubleshooting the Application 43 | Refer to the following guide if the application does not seem to be working as expected 44 | 45 | #### Application not working 46 | Before trying any of the technique below, make sure your application is correctly coded. 47 | 1. Clone this repository's master branch 48 | 2. Without making any changes to it, run with `npm install` and `npm start` 49 | 50 | This should solve 90% of errors. If not, see below: 51 | 52 | #### Correct admin priveleges (Mac only) 53 | 1. If you are using Mac, make sure that you installed Mac with [Brew](https://brew.sh/) so that proper admin priveleges are configured 54 | 55 | #### Install Global packages 56 | Depending on your OS, NPM may have a hard time running locally installed packages from the command line. To resolve this, manually install the dependencies under `devDependencies` with `npm install -g` or use the following script: 57 | 58 | ```bash 59 | npm install -g babel-loader@6.2.8 babel-plugin-transform-object-rest-spread@6.19.0 babel-preset-es2015@6.18.0 babel-preset-react@6.23.0 babel-regenerator-runtime@6.5.0 nodemon@1.11.0 webpack@1.13.3 webpack-dev-server@1.16.2 webpack-hot-middleware@2.17.1 webpack-dev-middleware@1.10.1 60 | ``` 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-messenger", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "babel-node server/server.js", 9 | "dev": "nodemon --watch server/**/* --exec babel-node --inspect server/server.js" 10 | }, 11 | "devDependencies": { 12 | "babel-cli": "^6.24.1", 13 | "babel-core": "^6.18.2", 14 | "babel-loader": "^6.2.8", 15 | "babel-plugin-transform-object-rest-spread": "^6.19.0", 16 | "babel-preset-es2015": "^6.18.0", 17 | "babel-preset-react": "^6.23.0", 18 | "babel-regenerator-runtime": "^6.5.0", 19 | "nodemon": "^1.11.0", 20 | "redux-devtools": "^3.3.2", 21 | "redux-devtools-dock-monitor": "^1.1.1", 22 | "redux-devtools-log-monitor": "^1.2.0", 23 | "webpack": "^1.13.3", 24 | "webpack-dev-middleware": "^1.10.1", 25 | "webpack-dev-server": "^1.16.2", 26 | "webpack-hot-middleware": "^2.17.1" 27 | }, 28 | "dependencies": { 29 | "chance": "^1.0.6", 30 | "classnames": "^2.2.5", 31 | "cors": "^2.8.1", 32 | "express": "^4.15.2", 33 | "immutable": "^3.8.1", 34 | "isomorphic-fetch": "^2.2.1", 35 | "lodash": "^4.17.4", 36 | "react": "^15.4.2", 37 | "react-dom": "^15.4.2", 38 | "react-redux": "^5.0.3", 39 | "redux": "^3.6.0", 40 | "redux-saga": "^0.14.3", 41 | "redux-thunk": "^2.2.0", 42 | "reselect": "^2.5.4", 43 | "socket.io": "^1.7.3" 44 | }, 45 | "keywords": [], 46 | "author": "", 47 | "license": "ISC" 48 | } 49 | -------------------------------------------------------------------------------- /public/css/main.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css?family=Montserrat:400,700|DroidSans:100,300,400|Roboto:400,700"); 2 | 3 | body { 4 | font-size: 16px; 5 | font-family: "Roboto"; 6 | } 7 | 8 | h1,h2,h3,h4 { 9 | font-family: "Montserrat"; 10 | } 11 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Redux Messaging App 5 | 6 | 7 | 8 | 9 |
10 |
11 | <%= html%> 12 |
13 |
14 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /server/db/Channel.js: -------------------------------------------------------------------------------- 1 | export const channels = []; 2 | 3 | export const Channel = (id)=>{ 4 | const channel = channels.find(channel=>channel.id===id); 5 | if (!channel) { 6 | throw new Error(`Could not find channel with ID ${id}`); 7 | } 8 | return channel; 9 | } -------------------------------------------------------------------------------- /server/db/User.js: -------------------------------------------------------------------------------- 1 | export const users = []; 2 | 3 | export const User = (id)=>{ 4 | const user = users.find(user=>user.id===id); 5 | if (!user) { 6 | throw new Error(`Could not find user with ID ${id}`); 7 | } 8 | return user; 9 | } -------------------------------------------------------------------------------- /server/db/index.js: -------------------------------------------------------------------------------- 1 | export { Channel, channels } from './Channel'; 2 | export { User, users } from './User'; 3 | export { getRandomMessageText } from './initializeDB'; -------------------------------------------------------------------------------- /server/db/initializeDB.js: -------------------------------------------------------------------------------- 1 | import { 2 | channels, 3 | users 4 | } from './' 5 | 6 | import { 7 | ONLINE, OFFLINE, AWAY, FETCHED 8 | } from './../../src/actions' 9 | 10 | import { 11 | template 12 | } from 'lodash'; 13 | 14 | import { chance } from './../../src/utility'; 15 | 16 | const messageActions = [`collate`,`port`,`merge`,`refactor`,`check`,`deploy`,`automate`,`debug`,`erase`,`cache`,`rebase`,`transpile`,`desync`,`fork`]; 17 | const messageObjects = [`repo`,`source code`,`code base`,`sprint`,`workflow`,`debugger`,`module`,`version`,`transpiler`,`language`,`integer`,`router`]; 18 | const templates = [ 19 | `I'm going to <%= action%> the <%= object%>.`, 20 | `Could you <%= action%> the <%= object%>?`, 21 | `I've noticed a <%= object%> that you could <%= action%>.`, 22 | `Is there a <%= object%> I could <%= action%>?`, 23 | `I'm thinking of attending a conference on <%= object%> management.`, 24 | `Do you know how to <%= action%> the <%= object%>?`, 25 | `The <%= object%> is amazing!`, 26 | `Note to self: <%= action%> the <%= object%>.`, 27 | ]; 28 | 29 | export const getRandomMessageText = ()=> template(chance.pick(templates))({ 30 | action: chance.pick(messageActions), 31 | object: chance.pick(messageObjects) 32 | }); 33 | 34 | export const getRandomMessage = (userIDs)=>({ 35 | id:chance.guid(), 36 | owner: chance.pick(userIDs), 37 | content: { 38 | text: getRandomMessageText() 39 | }, 40 | date:chance.date({year:2017}) 41 | }); 42 | 43 | export const initializeDB = ()=>{ 44 | 45 | const firstNames = [`Emily`,`Chuck`,`Andy`,`Edgar`,`Stephen`,`Pablo`,`Gustav`,`Jackson`,`Leonardo`]; 46 | const lastNames = [`McCartney`,`Webber`,`Combs`,`John`,`Martin`,`Starr`,`Springstein`,`Simmons`,`Harrison`]; 47 | 48 | let userCount = 12; 49 | while (userCount--) { 50 | users.push({ 51 | name:`${chance.pick(firstNames)} ${chance.pick(lastNames)}`, 52 | id: chance.guid(), 53 | contacts: [], 54 | channels:[], 55 | fetchStatus:FETCHED, 56 | status:chance.pick([ONLINE,OFFLINE,AWAY]) 57 | }) 58 | } 59 | 60 | let contactCount = 6; 61 | users.forEach(user=>{ 62 | for (let i = 0; i < contactCount; i++) { 63 | user.contacts.push(chance.pick(users.filter(({id}) => id !== user.id && !user.contacts.includes(id))).id); 64 | } 65 | }); 66 | 67 | 68 | const getMessages = (userIDs,count = 12)=>{ 69 | const messages = []; 70 | for (let i = count; i > 0; i--) { 71 | messages.push(getRandomMessage(userIDs)); 72 | }; 73 | return messages; 74 | }; 75 | 76 | users.forEach(user=>{ 77 | channels.push({ 78 | id:chance.guid(), 79 | name:`${user.name}'s Private Channel`, 80 | participants:[user.id], 81 | messages:getMessages([user.id]) 82 | }) 83 | }); 84 | 85 | users.forEach(user=>{ 86 | const user2 = chance.pick(users.filter(_user=>_user.id !== user.id)); 87 | channels.push({ 88 | id:chance.guid(), 89 | name:`${user.name} and ${user2.name}'s Private Chat`, 90 | participants:[user.id,user2.id], 91 | messages:getMessages([user.id,user2.id]) 92 | }) 93 | }); 94 | 95 | channels.push({ 96 | id:chance.guid(), 97 | name:`Group Chat`, 98 | participants:users.map(user=>user.id), 99 | messages:getMessages(users.map(user=>user.id),28) 100 | }); 101 | 102 | users.forEach(user=>{ 103 | user.activeChannel = chance.pick(channels.filter(channel=>channel.participants.includes(user.id))).id; 104 | }); 105 | }; -------------------------------------------------------------------------------- /server/getDefaultState.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable' 2 | 3 | import { 4 | User, 5 | channels, 6 | Channel 7 | } from './db'; 8 | 9 | import { 10 | NOT_FETCHED, 11 | FETCHED 12 | } from './../src/actions' 13 | 14 | export const getDefaultState = (currentUser)=>{ 15 | 16 | const defaultState = { 17 | currentUser:{}, 18 | channels:[], 19 | userInfo:[], 20 | }; 21 | 22 | const userChannels = channels.filter(channel=>channel.participants.includes(currentUser.id)); 23 | const activeChannel = Channel(currentUser.activeChannel); 24 | defaultState.currentUser = currentUser; 25 | defaultState.channels = userChannels.map(channel=>{ 26 | if (channel.id === activeChannel.id) { 27 | return { 28 | ...channel, 29 | fetchStatus:FETCHED 30 | }; 31 | } else { 32 | return { 33 | id:channel.id, 34 | name:channel.name, 35 | messages:[], 36 | fetchStatus:NOT_FETCHED, 37 | participants:channel.participants 38 | } 39 | } 40 | }); 41 | 42 | defaultState.activeChannel = activeChannel.id; 43 | defaultState.userInfo = [currentUser,...activeChannel.participants.map(User), ...currentUser.contacts.map(User)].map(user=>({ 44 | name:user.name, 45 | fetchStatus:FETCHED, 46 | id:user.id, 47 | status:user.status 48 | })); 49 | 50 | return fromJS(defaultState); 51 | }; -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import express from 'express'; 3 | import cors from 'cors'; 4 | import webpack from 'webpack'; 5 | import webpackConfig from './../webpack.config' 6 | import webpackDevMiddleware from 'webpack-dev-middleware'; 7 | import { simulateActivity } from './simulateActivity'; 8 | const compiler = webpack(webpackConfig); 9 | import webpackHotMiddleware from "webpack-hot-middleware"; 10 | 11 | import { 12 | channels, 13 | } from './db/Channel'; 14 | 15 | import { 16 | users 17 | } from './db/User'; 18 | 19 | import socketIO from 'socket.io' 20 | 21 | import { 22 | OFFLINE, 23 | ONLINE, 24 | AWAY 25 | } from './../src/actions' 26 | 27 | let app = express(); 28 | const server = http.createServer(app); 29 | const io = socketIO(server); 30 | 31 | app.use(cors()); 32 | app.use(webpackDevMiddleware(compiler, { 33 | noInfo: true, 34 | publicPath: webpackConfig.output.publicPath, 35 | })); 36 | 37 | app.use(webpackHotMiddleware(compiler, { 38 | 'log': false, 39 | 'path': '/__webpack_hmr', 40 | 'heartbeat': 10 * 1000 41 | })); 42 | 43 | import { getDefaultState } from './getDefaultState' 44 | import { handleRender } from './serverRenderMiddleWare'; 45 | import { initializeDB } from './db/initializeDB'; 46 | 47 | import { 48 | chance 49 | } from './../src/utility'; 50 | 51 | initializeDB(); 52 | const currentUser = chance.pick(users); 53 | 54 | // Simulate a small amount of delay to demonstrate app's async features 55 | app.use((req,res,next)=>{ 56 | const delay = 297; 57 | setTimeout(next,delay); 58 | }); 59 | 60 | app.use('/channel/create/:channelID/:name/:participants',({params:{channelID,name,participants}},res)=>{ 61 | const channel = { 62 | id:channelID, 63 | name, 64 | participants:JSON.parse(participants), 65 | messages:[] 66 | }; 67 | channels.push(channel); 68 | res.status(300).json(channel); 69 | }); 70 | 71 | app.use('/channel/:id',(req,res)=>{ 72 | res.json(channels.find(channel=>channel.id === req.params.id)); 73 | }); 74 | 75 | app.use('/user/activeChannel/:userID/:channelID',({params:{userID,channelID}},res)=>{ 76 | users.find(user=>user.id === userID).activeChannel = channelID; 77 | res.status(200).send(true); 78 | }); 79 | 80 | app.use('/user/:id',(req,res)=>{ 81 | res.json(users 82 | .map(({name,id})=>({name,id})) 83 | .find(user=>user.id === req.params.id)); 84 | }); 85 | 86 | app.use('/status/:id/:status',({params:{id,status}},res)=>{ 87 | if (![ONLINE,OFFLINE,AWAY].includes(status)) { 88 | return res.status(403).send(); 89 | } 90 | const user = users 91 | .find(user=>user.id === id); 92 | if (user) { 93 | user.status = status; 94 | res.status(200).send(); 95 | } else { 96 | res.status(404).send(); 97 | } 98 | }); 99 | 100 | export const createMessage = ({userID,channelID,messageID,input}) =>{ 101 | const channel = channels.find(channel=>channel.id === channelID); 102 | 103 | const message = { 104 | id:messageID, 105 | content:{ 106 | text:input 107 | }, 108 | owner:userID 109 | }; 110 | 111 | channel.messages.push(message); 112 | io.emit("NEW_MESSAGE",{channelID:channel.id, ...message}); 113 | } 114 | 115 | app.use('/input/submit/:userID/:channelID/:messageID/:input',({params:{userID,channelID,messageID,input}},res)=>{ 116 | const user = users.find(user=>user.id === userID); 117 | 118 | if (!user) { 119 | return res.status(404).send(); 120 | } 121 | 122 | createMessage({userID,channelID,messageID,input}); 123 | res.status(300).send(); 124 | }); 125 | 126 | app.use(express.static('public/css')); 127 | app.use('/',handleRender(()=>getDefaultState(currentUser))); 128 | 129 | const port = 9000; 130 | 131 | server.listen(port,()=>{ 132 | console.info(`Redux Messenger is listening on port ${port}.`); 133 | }); 134 | 135 | simulateActivity(currentUser.id); -------------------------------------------------------------------------------- /server/serverRenderMiddleWare.js: -------------------------------------------------------------------------------- 1 | import { createStore } from 'redux'; 2 | import { reducer } from './../src/reducers'; 3 | import {Provider} from 'react-redux'; 4 | import { App } from './../src/App' 5 | import { renderToString } from 'react-dom/server' 6 | import React from 'react'; 7 | import template from 'lodash/template'; 8 | 9 | import fs from 'fs'; 10 | 11 | const readModuleFile = (path, callback)=>{ 12 | try { 13 | const filename = require.resolve(path); 14 | fs.readFile(filename, 'utf8', callback); 15 | } catch (e) { 16 | callback(e); 17 | } 18 | } 19 | 20 | export const handleRender = (getState) => (req, res)=>{ 21 | let defaultState = getState(); 22 | const store = createStore(reducer,defaultState); 23 | 24 | const html = renderToString( 25 | 26 | 27 | 28 | ); 29 | 30 | const preloadedState = store.getState().toJS(); 31 | 32 | readModuleFile('./../public/index.html', (err, index)=>{ 33 | const templated = template(index)({ 34 | html, 35 | preloadedState:JSON.stringify(preloadedState).replace(/{ 20 | if (count < max) { 21 | count++; 22 | const input = getRandomMessageText(users.map(user=>user)); 23 | const messageID = chance.guid(); 24 | const channel = chance.pick(channels.filter(channel=>channel.participants.includes(user))); 25 | const channelID = channel.id; 26 | const userID = chance.pick(channel.participants); 27 | createMessage({userID,channelID,messageID,input}) 28 | } 29 | }; 30 | 31 | export function simulateActivity(userID){ 32 | setInterval(simulateCreateMessage,interval,userID); 33 | } -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | 4 | import { 5 | ContactListContainer, 6 | CurrentUserContainer, 7 | ChannelListContainer, 8 | ChannelContentContainer, 9 | CurrentChannelTextInputContainer 10 | } from './components'; 11 | 12 | export const App = ()=>( 13 |
14 | 21 |
22 |
23 |
24 | 25 |
26 |
27 |
28 |
29 | 30 |
31 |
32 | 33 |
34 | 35 |
36 |
37 |
38 | 39 |
40 |
41 | 42 |
43 |
44 |
45 |
46 | ); -------------------------------------------------------------------------------- /src/App.less: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielstern/advanced-redux/917b137e8528c264f8160545debe76f4ae9a6a84/src/App.less -------------------------------------------------------------------------------- /src/actions/completeChannelCreation.js: -------------------------------------------------------------------------------- 1 | import { makeActionCreator } from '../utility'; 2 | 3 | export const COMPLETE_CHANNEL_CREATION = `COMPLETE_CHANNEL_CREATION`; 4 | export const completeChannelCreation = makeActionCreator(COMPLETE_CHANNEL_CREATION, `channelID`, `success`); -------------------------------------------------------------------------------- /src/actions/index.js: -------------------------------------------------------------------------------- 1 | export { makeActionCreator } from '../utility/makeActionCreator'; 2 | export { setUserInfo, SET_USER_INFO } from './setUserInfo'; 3 | export { updateChannelInputText, UPDATE_CHANNEL_INPUT_TEXT } from './updateChannelInputText'; 4 | export { updateStatus, UPDATE_STATUS, AWAY, OFFLINE, ONLINE } from './updateStatus'; 5 | export { updateUserFetchStatus, UPDATE_USER_FETCH_STATUS } from './updateUserFetchStatus'; 6 | export { setActiveChannel, SET_ACTIVE_CHANNEL } from './setActiveChannel'; 7 | export { updateChannelLoadedStatus, UPDATE_CHANNEL_FETCHED_STATUS } from './updateChannelLoadedStatus'; 8 | export { setChannelInfo, SET_CHANNEL_INFO } from './setChannelInfo'; 9 | export { submitChannelInputText, SUBMIT_CHANNEL_INPUT_TEXT } from './submitChannelInputText' 10 | export { receiveMessage, RECEIVE_MESSAGE } from './receiveMessage'; 11 | export { openContactChannel } from './openContactChannel'; 12 | export { requestCreateChannel, REQUEST_CREATE_CHANNEL } from './requestCreateChannel'; 13 | export { completeChannelCreation, COMPLETE_CHANNEL_CREATION} from './completeChannelCreation'; 14 | 15 | export const NOT_FETCHED = `NOT_FETCHED`; 16 | export const FETCHING = `FETCHING`; 17 | export const FETCHED = `FETCHED`; 18 | -------------------------------------------------------------------------------- /src/actions/openContactChannel.js: -------------------------------------------------------------------------------- 1 | import { chance } from './../utility' 2 | 3 | import { 4 | setActiveChannel, 5 | requestCreateChannel 6 | } from './' 7 | 8 | import { 9 | currentUserSelector, 10 | userSelector 11 | } from './../selectors' 12 | 13 | export const openContactChannel = (id)=>(dispatch, getState)=>{ 14 | const state = getState(); 15 | const existingChannel = state.get(`channels`).find(channel=> channel.get(`participants`).size === 2 && channel.get(`participants`).includes(id)); 16 | if (existingChannel) { 17 | dispatch(setActiveChannel(existingChannel.get(`id`))); 18 | } else { 19 | const channelID = chance.guid(); 20 | const currentUserID = currentUserSelector(state).get(`id`); 21 | const channelName = `${currentUserSelector(state).get(`name`)} and ${userSelector(id)(state).get(`name`)}'s Private Chat`; 22 | dispatch(requestCreateChannel(channelID, id, currentUserID, channelName)); 23 | dispatch(setActiveChannel(channelID)); 24 | } 25 | }; -------------------------------------------------------------------------------- /src/actions/receiveMessage.js: -------------------------------------------------------------------------------- 1 | import { makeActionCreator } from '../utility'; 2 | 3 | export const RECEIVE_MESSAGE = `RECEIVE_MESSAGE`; 4 | export const receiveMessage = makeActionCreator(RECEIVE_MESSAGE, `message`); -------------------------------------------------------------------------------- /src/actions/requestCreateChannel.js: -------------------------------------------------------------------------------- 1 | import { makeActionCreator } from '../utility'; 2 | 3 | export const REQUEST_CREATE_CHANNEL = `REQUEST_CREATE_CHANNEL`; 4 | export const requestCreateChannel = makeActionCreator(REQUEST_CREATE_CHANNEL, `channelID`,`contactID`,`ownID`,`channelName`); -------------------------------------------------------------------------------- /src/actions/setActiveChannel.js: -------------------------------------------------------------------------------- 1 | import { makeActionCreator } from '../utility'; 2 | 3 | export const SET_ACTIVE_CHANNEL = `SET_ACTIVE_CHANNEL`; 4 | export const setActiveChannel = makeActionCreator(SET_ACTIVE_CHANNEL, `id`); -------------------------------------------------------------------------------- /src/actions/setChannelInfo.js: -------------------------------------------------------------------------------- 1 | import { makeActionCreator } from '../utility'; 2 | 3 | export const SET_CHANNEL_INFO = `SET_CHANNEL_INFO`; 4 | export const setChannelInfo = makeActionCreator(SET_CHANNEL_INFO, `channel`); -------------------------------------------------------------------------------- /src/actions/setUserInfo.js: -------------------------------------------------------------------------------- 1 | import { makeActionCreator } from '../utility/makeActionCreator'; 2 | 3 | export const SET_USER_INFO = `SET_USER_INFO`; 4 | export const setUserInfo = makeActionCreator(SET_USER_INFO, `user`); -------------------------------------------------------------------------------- /src/actions/submitChannelInputText.js: -------------------------------------------------------------------------------- 1 | import { chance } from './../utility'; 2 | import { currentUserSelector } from './../selectors' 3 | 4 | export const SUBMIT_CHANNEL_INPUT_TEXT = `SUBMIT_CHANNEL_INPUT_TEXT`; 5 | export const submitChannelInputText = (channel,text)=>(dispatch,getState)=>{ 6 | const state = getState(); 7 | dispatch({ 8 | type:SUBMIT_CHANNEL_INPUT_TEXT, 9 | channel, 10 | text, 11 | owner:currentUserSelector(state).get(`id`), 12 | id:chance.guid() 13 | }) 14 | }; -------------------------------------------------------------------------------- /src/actions/updateChannelInputText.js: -------------------------------------------------------------------------------- 1 | import { makeActionCreator } from './'; 2 | 3 | export const UPDATE_CHANNEL_INPUT_TEXT = `UPDATE_CHANNEL_INPUT_TEXT`; 4 | export const updateChannelInputText = makeActionCreator(UPDATE_CHANNEL_INPUT_TEXT, `channel`,`text`); -------------------------------------------------------------------------------- /src/actions/updateChannelLoadedStatus.js: -------------------------------------------------------------------------------- 1 | import { makeActionCreator } from './'; 2 | 3 | export const UPDATE_CHANNEL_FETCHED_STATUS = `UPDATE_CHANNEL_FETCHED_STATUS`; 4 | export const updateChannelLoadedStatus = makeActionCreator(UPDATE_CHANNEL_FETCHED_STATUS, `channel`,`status`); -------------------------------------------------------------------------------- /src/actions/updateStatus.js: -------------------------------------------------------------------------------- 1 | import { makeActionCreator } from './'; 2 | 3 | export const UPDATE_STATUS = `UPDATE_STATUS`; 4 | export const ONLINE = `ONLINE`; 5 | export const OFFLINE = `OFFLINE`; 6 | export const AWAY = `AWAY`; 7 | 8 | export const updateStatus = makeActionCreator(UPDATE_STATUS, `status`); 9 | -------------------------------------------------------------------------------- /src/actions/updateUserFetchStatus.js: -------------------------------------------------------------------------------- 1 | import { makeActionCreator } from '../utility/makeActionCreator'; 2 | 3 | export const UPDATE_USER_FETCH_STATUS = `UPDATE_USER_FETCH_STATUS`; 4 | export const updateUserFetchStatus = makeActionCreator(UPDATE_USER_FETCH_STATUS, `id`,`status`); -------------------------------------------------------------------------------- /src/combineReducers.js: -------------------------------------------------------------------------------- 1 | export const combineReducers = (config) =>{ 2 | return (state,action)=>{ 3 | return Object.keys(config).reduce((state,key)=>{ 4 | const reducer = config[key]; 5 | 6 | const previousState = state.get(key); 7 | 8 | const newValue = reducer(previousState,action); 9 | 10 | if (!newValue) { 11 | throw new Error(`A reducer returned undefined when reducing key::"${key}"`); 12 | } 13 | return state.set(key,newValue); 14 | },state); 15 | }; 16 | } -------------------------------------------------------------------------------- /src/components/ChannelContent/ChannelContent.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | OFFLINE, 4 | FETCHED, 5 | FETCHING 6 | } from './../../actions' 7 | import { MessageContainer } from './../Message/MessageContainer' 8 | export const ChannelContent = ({messages,channelName,status,fetchStatus})=>( 9 |
10 |

11 | Channel: {channelName} 12 |

13 | {status === OFFLINE ?
14 | Contacts in the channel will see you as offline. 15 |
: null} 16 |
17 | {fetchStatus !== FETCHED ? Please wait... : null} 18 | {messages.size === 0 && fetchStatus === FETCHED ? Be the first to say something. : null} 19 | {messages.map(message=>( 20 |
21 | 22 |
23 | ))} 24 |
25 |
26 | ); 27 | -------------------------------------------------------------------------------- /src/components/ChannelContent/ChannelContentContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { connect } from 'react-redux' 4 | 5 | import { 6 | ChannelContent 7 | } from './ChannelContent'; 8 | 9 | import { 10 | activeChannelSelector, 11 | } from './../../selectors' 12 | 13 | const mapStateToProps = (state) => { 14 | const channels = state.get(`channels`); 15 | const activeChannel = state.get(`activeChannel`); 16 | const channel = activeChannelSelector(state); 17 | 18 | return { 19 | messages:channel.get(`messages`), 20 | channelName:channel.get(`name`), 21 | fetchStatus:channel.get(`fetchStatus`), 22 | status:state.get(`currentUser`).get(`status`) 23 | } 24 | }; 25 | 26 | const mapDispatchToProps = (dispatch) => ({}); 27 | 28 | export const ChannelContentContainer = connect( 29 | mapStateToProps, 30 | mapDispatchToProps 31 | )(ChannelContent); -------------------------------------------------------------------------------- /src/components/ChannelList/ChannelList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {ChannelListItem} from './ChannelListItem' 3 | export const ChannelList = ({channels,activeChannel,setActiveChannel})=>( 4 |
5 |
6 |

Channels

7 |
8 |
9 | {channels.map(channel=> 10 | 16 | )} 17 |
18 |
19 | ); 20 | -------------------------------------------------------------------------------- /src/components/ChannelList/ChannelListContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { connect } from 'react-redux' 4 | 5 | import { 6 | ChannelList 7 | } from './ChannelList'; 8 | 9 | import { 10 | setActiveChannel 11 | } from './../../actions/setActiveChannel' 12 | 13 | const mapStateToProps = (state) => ({ 14 | channels:state.get(`channels`), 15 | activeChannel: state.get(`activeChannel`) 16 | }); 17 | 18 | const mapDispatchToProps = (dispatch) => ({ 19 | setActiveChannel:(channel)=>{ 20 | dispatch(setActiveChannel(channel)); 21 | } 22 | }); 23 | 24 | export const ChannelListContainer = connect( 25 | mapStateToProps, 26 | mapDispatchToProps 27 | )(ChannelList); -------------------------------------------------------------------------------- /src/components/ChannelList/ChannelListItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import classnames from 'classnames'; 3 | 4 | 5 | export const ChannelListItem = ({id,name,setActiveChannel,isActive})=>{ 6 | const className = classnames('list-group-item',{active:isActive}); 7 | return ( 8 | setActiveChannel(id)}>{name} 9 | ) 10 | }; -------------------------------------------------------------------------------- /src/components/ContactList/ContactList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { ContactListItemContainer } from './ContactListItemContainer' 3 | 4 | export const ContactList = ({contacts,name,openConversation})=>( 5 |
6 |
7 |

{name}'s Contacts

8 |
9 |
10 | {contacts.map(contact=>( 11 | 12 | ))} 13 |
14 |
15 | ); -------------------------------------------------------------------------------- /src/components/ContactList/ContactListContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { connect } from 'react-redux' 4 | 5 | import { 6 | ContactList 7 | } from './ContactList'; 8 | 9 | const mapStateToProps = (state) => { 10 | return { 11 | contacts:state.get(`currentUser`).get(`contacts`), 12 | name:state.get(`currentUser`).get(`name`) 13 | } 14 | }; 15 | 16 | export const ContactListContainer = connect( 17 | mapStateToProps 18 | )(ContactList); -------------------------------------------------------------------------------- /src/components/ContactList/ContactListItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { OFFLINE } from './../../actions' 3 | import Chance from 'chance'; 4 | 5 | export const ContactListItem = ({id,name,status,openChannel})=>( 6 |
7 |
8 |
9 | 10 | 11 | 12 |
13 |
14 |

{name}

15 | {status !== OFFLINE ? openChannel(id)} disabled={!status || status === OFFLINE}> 16 | Chat 17 | : (Offline)} 18 |
19 |
20 |
21 | ); -------------------------------------------------------------------------------- /src/components/ContactList/ContactListItemContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { connect } from 'react-redux' 4 | import { ContactListItem } from './ContactListItem'; 5 | import { userSelector } from './../../selectors' 6 | import { openContactChannel } from './../../actions' 7 | 8 | const mapStateToProps = (state, {id}) => { 9 | const contact = userSelector(id)(state); 10 | return { 11 | name:contact.get(`name`), 12 | id:contact.get(`id`), 13 | status:contact.get(`status`), 14 | } 15 | }; 16 | 17 | const mapDispatchToProps = (dispatch) => ({ 18 | openChannel(id){ 19 | dispatch(openContactChannel(id)); 20 | } 21 | }); 22 | 23 | export const ContactListItemContainer = connect( 24 | mapStateToProps, 25 | mapDispatchToProps 26 | )(ContactListItem); -------------------------------------------------------------------------------- /src/components/CurrentChannelTextInput/CurrentChannelTextInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | FETCHED, 4 | OFFLINE 5 | } from './../../actions' 6 | import classnames from 'classnames'; 7 | 8 | export const CurrentChannelTextInput = ({text = "",submitMessage,updateText,activeChannel,fetchStatus,userStatus})=>{ 9 | const buttonClass = classnames('btn','btn-default',{disabled:userStatus === OFFLINE}); 10 | return ( 11 |
12 |
{e.preventDefault();submitMessage(text,activeChannel)}}> 13 |
14 | updateText(e.target.value,activeChannel)} 20 | disabled={fetchStatus !== FETCHED || userStatus === OFFLINE} 21 | /> 22 | 23 | 24 | 25 |
26 |
27 | 28 |
29 | )}; 30 | -------------------------------------------------------------------------------- /src/components/CurrentChannelTextInput/CurrentChannelTextInputContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { connect } from 'react-redux' 4 | 5 | import { 6 | CurrentChannelTextInput 7 | } from './CurrentChannelTextInput'; 8 | 9 | import { 10 | updateChannelInputText, 11 | submitChannelInputText, 12 | } from './../../actions/'; 13 | 14 | import { 15 | activeChannelSelector, 16 | currentUserSelector 17 | } from './../../selectors' 18 | 19 | const mapStateToProps = (state) => { 20 | const activeChannel = activeChannelSelector(state); 21 | return { 22 | activeChannel:activeChannel.get(`id`), 23 | text:activeChannel.get(`currentUserText`), 24 | fetchStatus:activeChannel.get(`fetchStatus`), 25 | userStatus:currentUserSelector(state).get(`status`) 26 | } 27 | }; 28 | 29 | const mapDispatchToProps = (dispatch) => { 30 | return { 31 | updateText: (text,channel) => { 32 | dispatch(updateChannelInputText(channel,text)); 33 | }, 34 | submitMessage: (text,channel) => { 35 | dispatch(submitChannelInputText(channel,text)); 36 | } 37 | } 38 | }; 39 | 40 | export const CurrentChannelTextInputContainer = connect( 41 | mapStateToProps, 42 | mapDispatchToProps, 43 | )(CurrentChannelTextInput); -------------------------------------------------------------------------------- /src/components/CurrentUser/CurrentUser.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | ONLINE, 4 | OFFLINE, 5 | AWAY 6 | } from './../../actions' 7 | export const CurrentUser = ({name,status,updateStatus,id})=>( 8 |
9 |
10 |

Hi, {name}!

11 |
12 | {/**/} 13 | 14 | {/**/} 15 |
16 | 21 |
22 |
23 | ); 24 | -------------------------------------------------------------------------------- /src/components/CurrentUser/CurrentUserContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { updateStatus } from './../../actions' 4 | import { CurrentUser } from './CurrentUser'; 5 | 6 | const mapStateToProps = (state) => { 7 | const currentUser = state.get(`currentUser`); 8 | return { 9 | name:currentUser.get(`name`), 10 | status:currentUser.get(`status`), 11 | id:currentUser.get(`id`) 12 | } 13 | }; 14 | 15 | const mapDispatchToProps = (dispatch) => { 16 | return { 17 | updateStatus: ({target:{value}}) => { 18 | dispatch(updateStatus(value)); 19 | } 20 | } 21 | }; 22 | 23 | export const CurrentUserContainer = connect( 24 | mapStateToProps, 25 | mapDispatchToProps 26 | )(CurrentUser); -------------------------------------------------------------------------------- /src/components/DevTools/DevTools.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createDevTools } from 'redux-devtools'; 3 | 4 | import LogMonitor from 'redux-devtools-log-monitor'; 5 | import DockMonitor from 'redux-devtools-dock-monitor'; 6 | 7 | export const DevTools = createDevTools( 8 | 12 | 13 | 14 | ); -------------------------------------------------------------------------------- /src/components/Message/Message.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | export const Message = ({owner:{name},text})=>( 3 |
4 | 5 | {name} 6 | : {text} 7 |
8 | ) -------------------------------------------------------------------------------- /src/components/Message/MessageContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { connect } from 'react-redux' 4 | 5 | import { 6 | Message 7 | } from './Message'; 8 | 9 | import { userSelector } from './../../selectors' 10 | 11 | const mapStateToProps = (state, {message}) => { 12 | const owner = userSelector(message.get(`owner`))(state); 13 | return { 14 | text:message.get(`content`).get(`text`), 15 | owner:{ 16 | name:owner.get(`fetchStatus`).includes(`FETCHED`) ? owner.get(`name`) : `[...]` 17 | } 18 | } 19 | }; 20 | 21 | const mapDispatchToProps = (dispatch) => ({}); 22 | 23 | export const MessageContainer = connect( 24 | mapStateToProps, 25 | mapDispatchToProps 26 | )(Message); -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | export { ChannelContentContainer } from './ChannelContent/ChannelContentContainer'; 2 | export { ChannelListContainer } from './ChannelList/ChannelListContainer'; 3 | export { ContactListContainer } from './ContactList/ContactListContainer'; 4 | export { CurrentChannelTextInputContainer } from './CurrentChannelTextInput/CurrentChannelTextInputContainer'; 5 | export { CurrentUserContainer } from './CurrentUser/CurrentUserContainer'; 6 | export { DevTools } from './DevTools/DevTools'; 7 | -------------------------------------------------------------------------------- /src/getPreloadedState.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable' 2 | 3 | const preloadedState = fromJS(window.__PRELOADED_STATE__); 4 | delete window.__PRELOADED_STATE__; 5 | 6 | export const getPreloadedState = ()=>preloadedState; 7 | 8 | -------------------------------------------------------------------------------- /src/getStore.js: -------------------------------------------------------------------------------- 1 | import { 2 | createStore, 3 | applyMiddleware, 4 | compose 5 | } from 'redux'; 6 | 7 | import createSagaMiddleware from 'redux-saga'; 8 | 9 | import thunk from 'redux-thunk'; 10 | import { persistState } from 'redux-devtools'; 11 | import { getPreloadedState } from './getPreloadedState' 12 | import { getDebugSessionKey } from './utility' 13 | import { DevTools } from './components/DevTools/DevTools' 14 | import { initSagas } from './initSagas'; 15 | 16 | import { 17 | RECEIVE_MESSAGE, 18 | receiveMessage 19 | } from './actions' 20 | 21 | import { reducer } from './reducers'; 22 | import { createSocketMiddleware} from './socketMiddleware' 23 | 24 | const preloadedState = getPreloadedState(); 25 | const sagaMiddleware = createSagaMiddleware(); 26 | 27 | 28 | const io = window.io; 29 | 30 | const socketMiddleware = createSocketMiddleware(io)({ 31 | NEW_MESSAGE:(data)=>({ 32 | type:RECEIVE_MESSAGE, 33 | message:data 34 | }) 35 | }); 36 | 37 | const enhancer = compose( 38 | applyMiddleware( 39 | sagaMiddleware, 40 | thunk, 41 | socketMiddleware 42 | ), 43 | DevTools.instrument(), 44 | persistState(getDebugSessionKey()) 45 | ); 46 | 47 | const store = createStore( 48 | reducer, 49 | preloadedState, 50 | enhancer 51 | ); 52 | 53 | export const getStore = ()=>store; 54 | 55 | initSagas(sagaMiddleware); 56 | 57 | -------------------------------------------------------------------------------- /src/initSagas.js: -------------------------------------------------------------------------------- 1 | import * as sagas from './sagas'; 2 | 3 | export const initSagas = (sagaMiddleware)=>{ 4 | Object.values(sagas).forEach(sagaMiddleware.run.bind(sagaMiddleware)); 5 | }; -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import reactDOM from 'react-dom' 3 | import {getStore} from './getStore'; 4 | import {Provider} from 'react-redux'; 5 | 6 | import { DevTools } from './components/DevTools/DevTools' 7 | import { App } from './App'; 8 | 9 | const store = getStore(); 10 | 11 | const Main = ()=>( 12 | 13 | 14 | 15 | ) 16 | 17 | const render = (store)=>{ 18 | reactDOM.render( 19 |
20 |
21 | 22 |
, 23 | document.getElementById('AppContainer')); 24 | }; 25 | 26 | render(store); -------------------------------------------------------------------------------- /src/reducers/activeChannel.js: -------------------------------------------------------------------------------- 1 | import { SET_ACTIVE_CHANNEL } from './../actions/setActiveChannel' 2 | import { createReducer } from './../utility'; 3 | 4 | export const activeChannel = createReducer(null, { 5 | [SET_ACTIVE_CHANNEL](state,action) { 6 | return action.id; 7 | } 8 | }); -------------------------------------------------------------------------------- /src/reducers/channels.js: -------------------------------------------------------------------------------- 1 | import { 2 | UPDATE_CHANNEL_INPUT_TEXT, 3 | UPDATE_CHANNEL_FETCHED_STATUS, 4 | SET_CHANNEL_INFO, 5 | SUBMIT_CHANNEL_INPUT_TEXT, 6 | RECEIVE_MESSAGE, 7 | REQUEST_CREATE_CHANNEL, 8 | COMPLETE_CHANNEL_CREATION, 9 | FETCHED 10 | } from './../actions/' 11 | 12 | import { createReducer } from './../utility'; 13 | 14 | import { 15 | fromJS 16 | } from 'immutable'; 17 | 18 | const receiveMessageHandler = (state,{message:{id,channelID,content,owner}})=>{ 19 | const index = state.findIndex(channel=>channel.get(`id`) === channelID); 20 | if (index === -1) { 21 | return state; 22 | } 23 | let channel = state.get(index); 24 | let messages = channel.get(`messages`); 25 | 26 | if (messages.map(message=>message.get(`id`)).includes(id)) { 27 | return state; 28 | } 29 | 30 | let newMessages = messages.push(fromJS({ 31 | id, content, owner, date:new Date() 32 | })); 33 | 34 | return state.setIn([index,`messages`],newMessages); 35 | 36 | }; 37 | 38 | const requestCreateChannelHandler = (state,{ownID,contactID, channelID, channelName})=>{ 39 | return state.push(fromJS({ 40 | id:channelID, 41 | participants:[ownID,contactID], 42 | fetchStatus:`FETCHING`, 43 | messages:[], 44 | name:channelName 45 | })); 46 | }; 47 | 48 | const completeChannelCreationHandler = (state,{channelID,success})=>{ 49 | const index = state.findIndex(channel=>channel.get(`id`) === channelID); 50 | return state.setIn([index,`fetchStatus`],FETCHED); 51 | 52 | } 53 | 54 | export const channels = createReducer(null, { 55 | [COMPLETE_CHANNEL_CREATION]:completeChannelCreationHandler, 56 | [REQUEST_CREATE_CHANNEL]: requestCreateChannelHandler, 57 | [RECEIVE_MESSAGE]: receiveMessageHandler, 58 | [UPDATE_CHANNEL_INPUT_TEXT](state,action) { 59 | const index = state.findIndex(channel=>channel.get(`id`) === action.channel); 60 | return state.setIn([index,`currentUserText`],action.text); 61 | }, 62 | [SUBMIT_CHANNEL_INPUT_TEXT](state,action) { 63 | const index = state.findIndex(channel=>channel.get(`id`) === action.channel); 64 | let channel = state.get(index); 65 | let messages = channel.get(`messages`); 66 | let id = action.id; 67 | 68 | let newMessages = messages.push(fromJS({ 69 | id, 70 | content:{ 71 | text:action.text 72 | }, 73 | owner:action.owner, 74 | date:new Date() 75 | })); 76 | 77 | let newState = state.setIn([index,`messages`],newMessages); 78 | newState = newState.setIn([index,`currentUserText`],""); 79 | return newState; 80 | 81 | }, 82 | [UPDATE_CHANNEL_FETCHED_STATUS](state, action) { 83 | const index = state.findIndex(channel=>channel.get(`id`) === action.channel); 84 | return state.setIn([index,`fetchStatus`],action.status); 85 | }, 86 | [SET_CHANNEL_INFO](state,action) { 87 | const index = state.findIndex(channel=>channel.get(`id`) === action.channel.get(`id`)); 88 | return state.set(index,action.channel); 89 | }, 90 | }); -------------------------------------------------------------------------------- /src/reducers/currentUser.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from './../utility'; 2 | import { UPDATE_STATUS } from './../actions/' 3 | 4 | export const currentUser = createReducer(null, { 5 | [UPDATE_STATUS](state,action) { 6 | return state.set(`status`,action.status); 7 | } 8 | }); -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { channels } from './channels' 2 | import { currentUser } from './currentUser' 3 | import { activeChannel } from './activeChannel' 4 | import { userInfo } from './userInfo'; 5 | import { combineReducers } from './../combineReducers'; 6 | 7 | export const reducer = combineReducers({ 8 | currentUser, 9 | channels, 10 | activeChannel, 11 | userInfo, 12 | }); -------------------------------------------------------------------------------- /src/reducers/userInfo.js: -------------------------------------------------------------------------------- 1 | import { createReducer } from './../utility' 2 | import { Map } from 'immutable' 3 | import { 4 | UPDATE_USER_FETCH_STATUS, 5 | SET_USER_INFO, 6 | FETCHED 7 | } from './../actions' 8 | 9 | export const userInfo = createReducer(null, { 10 | [UPDATE_USER_FETCH_STATUS](state,action) { 11 | const index = state.findIndex(user=>user.get(`id`) === action.id); 12 | if (index === -1) { 13 | return state.push(Map({ 14 | id:action.id, 15 | fetchStatus:action.status, 16 | name:`[...]` 17 | })) 18 | } else { 19 | return state.setIn([index,`fetchStatus`],action.status); 20 | } 21 | }, 22 | [SET_USER_INFO](state,action) { 23 | const index = state.findIndex(user=>user.get(`id`) === action.user.get(`id`)); 24 | return state.set(index,action.user.set(`fetchStatus`,FETCHED)); 25 | }, 26 | 27 | }); -------------------------------------------------------------------------------- /src/sagas/activeChannelSaga.js: -------------------------------------------------------------------------------- 1 | import { SET_ACTIVE_CHANNEL } from './../actions' 2 | import { currentUserSelector } from './../selectors' 3 | import { takeLatest, call, select } from 'redux-saga/effects' 4 | 5 | function* putActiveChannel({id}){ 6 | const currentUser = yield select(currentUserSelector); 7 | const userID = currentUser.get(`id`); 8 | yield call(()=>fetch(`/user/activeChannel/${userID}/${id}`)); 9 | } 10 | 11 | export function* activeChannelSaga() { 12 | yield takeLatest(SET_ACTIVE_CHANNEL, putActiveChannel); 13 | } -------------------------------------------------------------------------------- /src/sagas/channelSaga.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable'; 2 | import { takeEvery, select, put, call } from 'redux-saga/effects' 3 | import { channelSelector } from './../selectors' 4 | import { 5 | FETCHED, 6 | NOT_FETCHED, 7 | SET_ACTIVE_CHANNEL, 8 | setChannelInfo, 9 | } from './../actions' 10 | 11 | function* fetchChannelInfo({id}){ 12 | const selector = channelSelector(id); 13 | const channel = yield select(selector); 14 | 15 | if (channel.get(`fetchStatus`) === NOT_FETCHED) { 16 | const response = yield call(()=>fetch(`/channel/${id}`)); 17 | const channelInfo = yield call(()=>response.json()); 18 | yield put(setChannelInfo(fromJS({fetchStatus:FETCHED,...channelInfo}))); 19 | } else { 20 | yield; 21 | } 22 | } 23 | 24 | export function* channelSaga() { 25 | yield takeEvery(SET_ACTIVE_CHANNEL, fetchChannelInfo); 26 | } -------------------------------------------------------------------------------- /src/sagas/createChannelSaga.js: -------------------------------------------------------------------------------- 1 | import { takeEvery, put, call } from 'redux-saga/effects' 2 | 3 | import { 4 | REQUEST_CREATE_CHANNEL, 5 | completeChannelCreation 6 | } from './../actions' 7 | 8 | function* requestCreateChannel({ownID,contactID,channelName,channelID}) { 9 | const participants = JSON.stringify([ownID,contactID]); 10 | yield call(()=>fetch(`/channel/create/${channelID}/${channelName}/${participants}`)); 11 | yield put(completeChannelCreation(channelID,true)); 12 | } 13 | 14 | export function* createChannelSaga() { 15 | yield takeEvery(REQUEST_CREATE_CHANNEL, requestCreateChannel); 16 | } -------------------------------------------------------------------------------- /src/sagas/currentChannelInputSaga.js: -------------------------------------------------------------------------------- 1 | import { SUBMIT_CHANNEL_INPUT_TEXT } from './../actions' 2 | import { takeEvery, call } from 'redux-saga/effects' 3 | 4 | function* submitChannelInputText({channel,text,owner,id}){ 5 | const channelID = channel; 6 | yield call(()=>fetch(`/input/submit/${owner}/${channelID}/${id}/${text}`)); 7 | } 8 | 9 | export function* currentChannelInputSaga() { 10 | yield takeEvery(SUBMIT_CHANNEL_INPUT_TEXT, submitChannelInputText); 11 | } -------------------------------------------------------------------------------- /src/sagas/currentUserStatusSaga.js: -------------------------------------------------------------------------------- 1 | import { UPDATE_STATUS } from './../actions' 2 | import { currentUserSelector } from './../selectors' 3 | import { takeLatest, call, select } from 'redux-saga/effects' 4 | 5 | function* putUserStatus({status}){ 6 | const currentUser = yield select(currentUserSelector); 7 | const id = currentUser.get(`id`); 8 | yield call(()=>fetch(`/status/${id}/${status}`)); 9 | } 10 | 11 | export function* currentUserStatusSaga() { 12 | yield takeLatest(UPDATE_STATUS, putUserStatus); 13 | } -------------------------------------------------------------------------------- /src/sagas/index.js: -------------------------------------------------------------------------------- 1 | export { userInfoSaga } from './userInfoSaga' 2 | export { channelSaga } from './channelSaga' 3 | export { currentUserStatusSaga } from './currentUserStatusSaga' 4 | export { currentChannelInputSaga } from './currentChannelInputSaga'; 5 | export { createChannelSaga } from './createChannelSaga' 6 | export { activeChannelSaga } from './activeChannelSaga' -------------------------------------------------------------------------------- /src/sagas/userInfoSaga.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-fetch'; 2 | import { fromJS } from 'immutable'; 3 | import { 4 | call, 5 | put, 6 | takeEvery, 7 | select, 8 | fork 9 | } from 'redux-saga/effects' 10 | 11 | import { SET_CHANNEL_INFO } from './../actions' 12 | import { userSelector } from './../selectors' 13 | import { 14 | updateUserFetchStatus, 15 | setUserInfo, 16 | FETCHING, 17 | FETCHED, 18 | NOT_FETCHED 19 | } from './../actions' 20 | 21 | function* fetchUserInfo(id){ 22 | const user = yield select(userSelector(id)); 23 | 24 | if (user.get(`fetchStatus`) === NOT_FETCHED) { 25 | yield put(updateUserFetchStatus(id,FETCHING)); 26 | const response = yield call(()=>fetch(`/user/${id}`)); 27 | const userInfo = yield call(()=>response.json()); 28 | yield put(updateUserFetchStatus(id,FETCHED)); 29 | yield put(setUserInfo(fromJS(userInfo))); 30 | } 31 | } 32 | 33 | function* fetchChannelUsers({channel}){ 34 | const ids = channel.get(`participants`); 35 | for (let id of ids) { 36 | yield fork(fetchUserInfo,id); 37 | } 38 | } 39 | 40 | export function* userInfoSaga() { 41 | yield takeEvery(SET_CHANNEL_INFO, fetchChannelUsers); 42 | } -------------------------------------------------------------------------------- /src/selectors/activeChannelSelector.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | export const activeChannelSelector = createSelector( 4 | state=>state.get(`activeChannel`), 5 | state=>state.get(`channels`), 6 | (activeChannel,channels)=>{return channels.find(channel=>channel.get(`id`)=== activeChannel)} 7 | ); -------------------------------------------------------------------------------- /src/selectors/channelSelector.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | export const channelSelector = (id)=>createSelector( 4 | state=>state.get(`channels`), 5 | (channels)=>channels.find(channel=>channel.get(`id`) === id) 6 | ); -------------------------------------------------------------------------------- /src/selectors/currentUser.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | export const currentUserSelector = createSelector( 4 | state=>state.get(`currentUser`), 5 | currentUser=>currentUser 6 | ); 7 | -------------------------------------------------------------------------------- /src/selectors/index.js: -------------------------------------------------------------------------------- 1 | export { channelSelector } from './channelSelector'; 2 | export { userSelector } from './userSelector'; 3 | export { activeChannelSelector } from './activeChannelSelector'; 4 | export { currentUserSelector } from './currentUser'; -------------------------------------------------------------------------------- /src/selectors/userSelector.js: -------------------------------------------------------------------------------- 1 | import { 2 | fromJS 3 | } from 'immutable' 4 | 5 | import { createSelector } from 'reselect'; 6 | 7 | export const userSelector = (id)=>createSelector( 8 | state=>state.get(`userInfo`), 9 | userInfo=>{ 10 | const user = userInfo.find(user=>user.get(`id`)===id); 11 | if (user) { 12 | return user; 13 | } else { 14 | return fromJS({ 15 | name:"[...]", 16 | fetchStatus:"NOT_FETCHED", 17 | id 18 | }); 19 | } 20 | } 21 | ); 22 | -------------------------------------------------------------------------------- /src/socketMiddleware.js: -------------------------------------------------------------------------------- 1 | export const createSocketMiddleware = io => config => { 2 | const socket = io(); 3 | return store => next => action => { 4 | for (const key in config) { 5 | socket.on(key, config[key]); 6 | } 7 | let result = next(action); 8 | return result; 9 | }; 10 | } -------------------------------------------------------------------------------- /src/utility/chance.js: -------------------------------------------------------------------------------- 1 | import Chance from 'chance'; 2 | export const chance = new Chance(); -------------------------------------------------------------------------------- /src/utility/createReducer.js: -------------------------------------------------------------------------------- 1 | export const createReducer = (initialState, handlers)=>{ 2 | return function reducer(state = initialState, action) { 3 | if (handlers.hasOwnProperty(action.type)) { 4 | return handlers[action.type](state, action) 5 | } else { 6 | return state 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /src/utility/getDebugSessionKey.js: -------------------------------------------------------------------------------- 1 | export const getDebugSessionKey = ()=>{ 2 | if (typeof (window) !== 'undefined') { 3 | const matches = window.location.href.match(/[?&]debug_session=([^&#]+)\b/); 4 | return (matches && matches.length > 0)? matches[1] : null; 5 | } else { 6 | return null; 7 | } 8 | 9 | }; -------------------------------------------------------------------------------- /src/utility/index.js: -------------------------------------------------------------------------------- 1 | export { chance } from './chance'; 2 | export { makeActionCreator } from './makeActionCreator'; 3 | export { createReducer } from './createReducer'; 4 | export { getDebugSessionKey } from './getDebugSessionKey' -------------------------------------------------------------------------------- /src/utility/makeActionCreator.js: -------------------------------------------------------------------------------- 1 | export const makeActionCreator = (type, ...argNames) => { 2 | return function(...args) { 3 | let action = { type }; 4 | argNames.forEach((arg, index) => { 5 | action[argNames[index]] = args[index] 6 | }); 7 | return action 8 | } 9 | }; -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | module.exports = { 4 | module: { 5 | loaders: [ 6 | { 7 | loader: "babel-loader", 8 | exclude: [ 9 | /(node_modules)/, 10 | ], 11 | query: { 12 | presets: ['es2015','react'], 13 | plugins: ['transform-object-rest-spread'] 14 | } 15 | } 16 | ] 17 | }, 18 | plugins: [ 19 | new webpack.HotModuleReplacementPlugin(), 20 | new webpack.NoErrorsPlugin() 21 | ], 22 | entry: { 23 | "index": [ 24 | 'babel-regenerator-runtime', 25 | 'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000&reload=true', 26 | './src/main' 27 | ] 28 | }, 29 | output: { 30 | path: path.resolve(__dirname, "public"), 31 | publicPath: "/assets", 32 | filename: "[name].bundle.js" 33 | }, 34 | resolve: { 35 | extensions: ['', '.js', '.jsx'], 36 | }, 37 | devServer: { inline: true }, 38 | devtool: 'source-map' 39 | }; --------------------------------------------------------------------------------