├── apps └── chat │ ├── __init__.py │ ├── tests │ ├── __init__.py │ ├── test_engine │ │ ├── __init__.py │ │ ├── test_utils.py │ │ └── test_chat.py │ ├── test_views.py │ ├── factories.py │ ├── test_consumers.py │ └── test_models.py │ ├── migrations │ ├── __init__.py │ └── 0001_initial.py │ ├── engine │ ├── __init__.py │ ├── utils.py │ ├── constants.py │ ├── base.py │ └── chat.py │ ├── apps.py │ ├── urls.py │ ├── views.py │ ├── admin.py │ ├── consumers.py │ ├── templates │ ├── chat │ │ └── index.html │ └── base.html │ └── models.py ├── project ├── __init__.py ├── .gitignore ├── asgi.py ├── wsgi.py ├── urls.py ├── routing.py └── settings.py ├── .bowerrc ├── static ├── .gitignore ├── docs │ └── channel-chat.gif ├── less │ ├── variables.less │ ├── bootstrap.less │ └── base.less └── js │ ├── constants.js │ ├── components │ ├── __tests__ │ │ ├── utils.js │ │ ├── MessageList-test.react.js │ │ ├── ChatApp-test.react.js │ │ ├── RoomList-test.react.js │ │ ├── Message-test.react.js │ │ ├── Room-test.react.js │ │ ├── ChatRoom-test.react.js │ │ ├── Login-test.react.js │ │ └── Author-test.react.js │ ├── ChatApp.react.js │ ├── Room.react.js │ ├── RoomList.react.js │ ├── Message.react.js │ ├── Author.react.js │ ├── Login.react.js │ ├── ChatRoom.react.js │ └── MessageList.react.js │ ├── __tests__ │ ├── setup.js │ ├── actions-test.js │ └── reducers-test.js │ ├── containers │ ├── ActiveRoomList.react.js │ ├── VisibleChatRoom.react.js │ └── Root.react.js │ ├── index.react.js │ ├── actions.js │ ├── reducers.js │ └── utils │ └── ChatAPI.js ├── .babelrc ├── requirements ├── dev.txt └── base.txt ├── setup.cfg ├── .gitignore ├── .eslintrc.json ├── .isort.cfg ├── manage.py ├── Makefile ├── LICENSE ├── package.json └── README.rst /apps/chat/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/chat/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/chat/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "analytics": false 3 | } -------------------------------------------------------------------------------- /apps/chat/tests/test_engine/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /project/.gitignore: -------------------------------------------------------------------------------- 1 | local_settings.py 2 | -------------------------------------------------------------------------------- /static/.gitignore: -------------------------------------------------------------------------------- 1 | /bundle.js 2 | /style.min.css 3 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react" 5 | ] 6 | } -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | flake8==2.5.4 4 | isort==4.2.5 5 | 6 | ipython==5.0.0 7 | -------------------------------------------------------------------------------- /apps/chat/engine/__init__.py: -------------------------------------------------------------------------------- 1 | from .chat import ChatEngine 2 | 3 | __all__ = [ 4 | 'ChatEngine', 5 | ] 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # http://flake8.readthedocs.org/en/latest/config.html#global 3 | ignore = E501 4 | exclude = ._* -------------------------------------------------------------------------------- /static/docs/channel-chat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johnpaulett/channel_chat/HEAD/static/docs/channel-chat.gif -------------------------------------------------------------------------------- /apps/chat/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ChatConfig(AppConfig): 5 | name = 'chat' 6 | -------------------------------------------------------------------------------- /apps/chat/engine/utils.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | 3 | 4 | def timestamp(dt): 5 | return int(calendar.timegm(dt.timetuple())) 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.log 4 | *.egg-info 5 | *\~ 6 | *\#* 7 | 8 | /collected_static/ 9 | /env/ 10 | /node_modules/ 11 | -------------------------------------------------------------------------------- /apps/chat/engine/constants.py: -------------------------------------------------------------------------------- 1 | # Mirrors items reused server-side from constants.js 2 | 3 | LOGIN_SUCCESS = 'LOGIN_SUCCESS' 4 | RECEIVE_ROOMS = 'RECEIVE_ROOMS' 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | /* 2 | * http://eslint.org/ 3 | */ 4 | { 5 | // https://github.com/airbnb/javascript 6 | "extends": "airbnb", 7 | "env": { 8 | "mocha": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /static/less/variables.less: -------------------------------------------------------------------------------- 1 | @footer-height: 25px; 2 | @footer-bg: @panel-footer-bg; 3 | @footer-padding-vertical: @padding-base-vertical; 4 | 5 | // Bootstrap overrides 6 | @icon-font-path: ""; 7 | -------------------------------------------------------------------------------- /project/asgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from channels.asgi import get_channel_layer 4 | 5 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings') 6 | 7 | channel_layer = get_channel_layer() -------------------------------------------------------------------------------- /apps/chat/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | url(r'^$', views.index, name='index'), 7 | url(r'^robots.txt', views.robots), 8 | ] 9 | -------------------------------------------------------------------------------- /project/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings') 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | from django.contrib import admin 3 | 4 | urlpatterns = [ 5 | url(r'^admin/', admin.site.urls), 6 | 7 | url(r'^', include('chat.urls')), 8 | ] 9 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | # Python import sorting configuration 2 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 3 | [settings] 4 | line_length=80 5 | multi_line_output=5 6 | include_trailing_comma=true 7 | combine_as_imports=true 8 | from_first=true -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == '__main__': 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings') 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /project/routing.py: -------------------------------------------------------------------------------- 1 | from channels.routing import route 2 | from chat.consumers import ws_connect, ws_message, ws_disconnect 3 | 4 | 5 | channel_routing = [ 6 | route('websocket.connect', ws_connect), 7 | route('websocket.receive', ws_message), 8 | route('websocket.disconnect', ws_disconnect), 9 | ] 10 | -------------------------------------------------------------------------------- /apps/chat/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.shortcuts import render 3 | 4 | 5 | def index(request): 6 | return render(request, 'chat/index.html') 7 | 8 | 9 | def robots(request): 10 | """`robot.txt `_""" 11 | return HttpResponse('User-agent: *\nDisallow: /') 12 | -------------------------------------------------------------------------------- /static/js/constants.js: -------------------------------------------------------------------------------- 1 | import keyMirror from 'keymirror'; 2 | 3 | const ActionTypes = keyMirror({ 4 | LOGIN: null, 5 | LOGIN_SUCCESS: null, 6 | SELECT_ROOM: null, 7 | SEND_MESSAGE: null, 8 | REQUEST_MESSAGES: null, 9 | RECEIVE_MESSAGES: null, 10 | RECEIVE_ROOMS: null, 11 | }); 12 | 13 | export default ActionTypes; 14 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | Django==1.9.7 2 | 3 | pytz==2016.4 4 | psycopg2==2.6.2 5 | 6 | # Channels 7 | channels==0.16.0 8 | daphne==0.14.2 9 | asgi_redis==0.13.1 10 | redis==2.10.5 11 | 12 | # Celery 13 | celery==3.1.23 14 | kombu==3.0.35 15 | 16 | # Testing 17 | factory_boy==2.7.0 18 | fake_factory==0.5.9 19 | python_dateutil==2.5.3 20 | -------------------------------------------------------------------------------- /static/js/components/__tests__/utils.js: -------------------------------------------------------------------------------- 1 | export const sampleMessages = [ 2 | { 3 | id: 5, 4 | room: 'ted', 5 | user: 'ted', 6 | content: 'hey', 7 | timestamp: 1468461958836, 8 | }, 9 | { 10 | id: 6, 11 | room: 'ted', 12 | user: 'bob', 13 | content: 'good evening', 14 | timestamp: 1468461958900, 15 | }, 16 | ]; 17 | -------------------------------------------------------------------------------- /apps/chat/tests/test_engine/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.test import SimpleTestCase 2 | from chat.engine.utils import timestamp 3 | 4 | from datetime import datetime 5 | 6 | 7 | class TimestampTest(SimpleTestCase): 8 | def test(self): 9 | self.assertEqual( 10 | timestamp(datetime(2016, 7, 20, 9, 46, 39)), 11 | 1469007999 12 | ) 13 | -------------------------------------------------------------------------------- /apps/chat/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | 4 | class IndexViewTest(TestCase): 5 | def test(self): 6 | resp = self.client.get('/') 7 | self.assertContains(resp, 'Channel Chat') 8 | 9 | 10 | class RobotsTxtViewTest(TestCase): 11 | def test(self): 12 | resp = self.client.get('/robots.txt') 13 | self.assertContains(resp, 'Disallow: /') 14 | -------------------------------------------------------------------------------- /apps/chat/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from . import models 3 | 4 | 5 | class MessageAdmin(admin.ModelAdmin): 6 | list_display = ('room', 'timestamp') 7 | admin.site.register(models.Message, MessageAdmin) 8 | 9 | 10 | class RoomAdmin(admin.ModelAdmin): 11 | pass 12 | admin.site.register(models.Room, RoomAdmin) 13 | 14 | 15 | class UserAdmin(admin.ModelAdmin): 16 | pass 17 | admin.site.register(models.User, UserAdmin) 18 | -------------------------------------------------------------------------------- /apps/chat/consumers.py: -------------------------------------------------------------------------------- 1 | from channels.sessions import channel_session 2 | 3 | from .engine import ChatEngine 4 | 5 | 6 | @channel_session 7 | def ws_connect(message): 8 | # TODO Move many LOGIN_USER actions from ws_message into ws_add 9 | pass 10 | 11 | 12 | @channel_session 13 | def ws_message(message): 14 | ChatEngine.dispatch(message) 15 | 16 | 17 | @channel_session 18 | def ws_disconnect(message): 19 | ChatEngine(message).disconnect() 20 | -------------------------------------------------------------------------------- /static/js/components/ChatApp.react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ActiveRoomList from '../containers/ActiveRoomList.react'; 3 | import VisibleChatRoom from '../containers/VisibleChatRoom.react'; 4 | 5 | 6 | const ChatApp = () => ( 7 |
8 |
9 | 10 |
11 |
12 | 13 |
14 |
15 | ); 16 | 17 | export default ChatApp; 18 | -------------------------------------------------------------------------------- /static/js/__tests__/setup.js: -------------------------------------------------------------------------------- 1 | import { jsdom } from 'jsdom'; 2 | 3 | const exposedProperties = ['window', 'navigator', 'document']; 4 | 5 | global.document = jsdom(''); 6 | global.window = document.defaultView; 7 | Object.keys(document.defaultView).forEach((property) => { 8 | if (typeof global[property] === 'undefined') { 9 | exposedProperties.push(property); 10 | global[property] = document.defaultView[property]; 11 | } 12 | }); 13 | 14 | global.navigator = { 15 | userAgent: 'node.js', 16 | }; 17 | -------------------------------------------------------------------------------- /static/less/bootstrap.less: -------------------------------------------------------------------------------- 1 | /* Sticky Footer */ 2 | html { 3 | position: relative; 4 | min-height: 100%; 5 | } 6 | body { 7 | /* Margin bottom by footer height */ 8 | margin-bottom: @footer-height; 9 | } 10 | footer { 11 | position: absolute; 12 | bottom: 0; 13 | width: 100%; 14 | /* Set the fixed height of the footer here */ 15 | height: @footer-height; 16 | background-color: @footer-bg; 17 | 18 | 19 | padding-top: 5px; 20 | text-align: center; 21 | font-size: @font-size-small; 22 | } 23 | -------------------------------------------------------------------------------- /static/js/components/Room.react.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const RoomItem = ({ name, open, active, onClick }) => ( 4 |
  • 5 | 6 | {name} 7 |
  • 8 | ); 9 | 10 | RoomItem.propTypes = { 11 | name: PropTypes.string.isRequired, 12 | open: PropTypes.bool.isRequired, 13 | // active: PropTypes.bool.isRequired, 14 | onClick: PropTypes.func.isRequired, 15 | }; 16 | 17 | export default RoomItem; 18 | -------------------------------------------------------------------------------- /static/js/containers/ActiveRoomList.react.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { selectRoom } from '../actions'; 3 | import RoomList from '../components/RoomList.react'; 4 | 5 | 6 | const mapStateToProps = (state) => ({ 7 | currentRoomId: state.currentRoomId, 8 | rooms: state.rooms, 9 | }); 10 | 11 | const mapDispatchToProps = (dispatch) => ({ 12 | onRoomClick: (room) => { 13 | dispatch(selectRoom(room)); 14 | }, 15 | }); 16 | 17 | const ActiveRoomList = connect( 18 | mapStateToProps, 19 | mapDispatchToProps 20 | )(RoomList); 21 | 22 | export default ActiveRoomList; 23 | -------------------------------------------------------------------------------- /apps/chat/templates/chat/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | Fork me on GitHub 5 |
    6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /static/js/components/__tests__/MessageList-test.react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { expect } from 'chai'; 4 | 5 | import { sampleMessages } from './utils'; 6 | 7 | import MessageList from '../MessageList.react'; 8 | import Message from '../Message.react'; 9 | 10 | 11 | describe('', () => { 12 | it('renders two ', () => { 13 | const wrapper = shallow( 14 | 15 | ); 16 | 17 | expect(wrapper.hasClass('message-list')).to.equal(true); 18 | expect(wrapper.find(Message)).to.have.length(2); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /static/js/components/__tests__/ChatApp-test.react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { expect } from 'chai'; 4 | 5 | import ChatApp from '../ChatApp.react'; 6 | import ActiveRoomList from '../../containers/ActiveRoomList.react'; 7 | import VisibleChatRoom from '../../containers/VisibleChatRoom.react'; 8 | 9 | 10 | describe('', () => { 11 | it('renders and ', () => { 12 | const wrapper = shallow( 13 | 14 | ); 15 | expect(wrapper.find(ActiveRoomList)).to.have.length(1); 16 | expect(wrapper.find(VisibleChatRoom)).to.have.length(1); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /static/js/components/__tests__/RoomList-test.react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { expect } from 'chai'; 4 | 5 | import RoomList from '../RoomList.react'; 6 | import Room from '../Room.react'; 7 | 8 | 9 | describe('', () => { 10 | const rooms = [ 11 | { id: 2, name: 'ted', active: false }, 12 | { id: 3, name: 'alic', active: true }, 13 | ]; 14 | 15 | it('renders two ', () => { 16 | const wrapper = shallow( 17 | 18 | ); 19 | 20 | expect(wrapper.find('ul').hasClass('room-list')).to.equal(true); 21 | expect(wrapper.find(Room)).to.have.length(2); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /static/js/components/RoomList.react.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import Room from './Room.react'; 3 | 4 | 5 | const RoomList = ({ rooms, currentRoomId, onRoomClick }) => ( 6 |
    7 |

    Rooms

    8 |
      9 | {rooms.map(room => 10 | onRoomClick(room)} /> 11 | )} 12 |
    13 |
    14 | ); 15 | 16 | RoomList.propTypes = { 17 | rooms: PropTypes.arrayOf(PropTypes.shape({ 18 | id: PropTypes.number.isRequired, 19 | name: PropTypes.string.isRequired, 20 | // active: PropTypes.bool.isRequired, 21 | }).isRequired).isRequired, 22 | currentRoomId: PropTypes.number, 23 | onRoomClick: PropTypes.func.isRequired, 24 | }; 25 | 26 | export default RoomList; 27 | -------------------------------------------------------------------------------- /static/js/components/__tests__/Message-test.react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { expect } from 'chai'; 4 | 5 | import Message from '../Message.react'; 6 | 7 | 8 | describe('', () => { 9 | it('renders content', () => { 10 | const ts = 1468461958836; 11 | const wrapper = shallow( 12 | 13 | ); 14 | expect(wrapper.hasClass('message')).to.equal(true); 15 | 16 | // Had issues using .contains(), so using find().text() 17 | expect(wrapper.find('.user').text()).to.equal('bob:'); 18 | expect(wrapper.find('.timestamp').text()).to.equal( 19 | 'September 16th, 11:40 AM' 20 | ); 21 | expect(wrapper.find('.content').text()).to.equal('hello world'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /static/js/components/Message.react.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import dateFormat from 'dateformat'; 3 | 4 | 5 | const Message = ({ user, content, timestamp }) => { 6 | // Server returns normal unix timestamp in seconds, but Javascript 7 | // uses milliseconds 8 | const date = new Date(timestamp * 1000); 9 | return ( 10 |
    11 | {user}: 12 | {content} 13 | 14 | {dateFormat(date, 'mmmm dS, h:MM TT')} 15 | 16 |
    17 | ); 18 | }; 19 | 20 | Message.propTypes = { 21 | user: PropTypes.string.isRequired, 22 | content: PropTypes.string.isRequired, 23 | timestamp: PropTypes.number.isRequired, 24 | }; 25 | 26 | export default Message; 27 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PYTHON = env/bin/python3 2 | PIP = env/bin/pip 3 | MANAGE = $(PYTHON) manage.py 4 | 5 | .PHONY: static 6 | 7 | env: requirements 8 | test -f $(PYTHON) || virtualenv --python=python3 --no-site-packages env 9 | $(PIP) install -U -r requirements/dev.txt 10 | 11 | serve: 12 | $(MANAGE) runserver 0.0.0.0:5000 13 | 14 | shell: 15 | $(MANAGE) shell 16 | 17 | createdb: 18 | sudo -u postgres createuser -d -A -R -P channel_chat 19 | sudo -u postgres createdb -E UTF8 -O channel_chat channel_chat 20 | 21 | migrate: 22 | $(MANAGE) migrate 23 | 24 | static: 25 | # Assumes prior `nvm use 6` 26 | npm install 27 | npm run less 28 | npm run build 29 | $(MANAGE) collectstatic --noinput 30 | 31 | test-py: 32 | $(MANAGE) test apps 33 | 34 | test-js: 35 | # Assumes prior `nvm use 6` 36 | npm test 37 | 38 | test: test-py test-js 39 | -------------------------------------------------------------------------------- /static/js/components/__tests__/Room-test.react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { expect } from 'chai'; 4 | import sinon from 'sinon'; 5 | 6 | import Room from '../Room.react'; 7 | 8 | 9 | describe('', () => { 10 | it('renders content', () => { 11 | const onClick = sinon.spy(); 12 | const wrapper = shallow( 13 | 14 | ); 15 | 16 | expect(wrapper.hasClass('room')).to.equal(true); 17 | expect(wrapper.find('.room').text()).to.equal('bob'); 18 | }); 19 | 20 | it('handles click', () => { 21 | const onClick = sinon.spy(); 22 | const wrapper = shallow( 23 | 24 | ); 25 | wrapper.find('.room').simulate('click'); 26 | 27 | expect(onClick).to.have.property('callCount', 1); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /static/less/base.less: -------------------------------------------------------------------------------- 1 | @import "../../node_modules/bootstrap/less/bootstrap.less"; 2 | @import "variables.less"; 3 | @import "bootstrap.less"; 4 | 5 | 6 | body { 7 | padding-top: @navbar-height; 8 | padding-bottom: 20px; 9 | 10 | font-weight: 200; 11 | } 12 | 13 | .navbar { 14 | margin-bottom: @navbar-height; 15 | } 16 | 17 | .login-form { 18 | margin-top: 100px; 19 | } 20 | 21 | .message-list { 22 | overflow-y: scroll; 23 | height: 300px; 24 | } 25 | .message { 26 | .user { 27 | font-weight: bold; 28 | padding-right: 3px; 29 | } 30 | .timestamp { 31 | .text-muted; 32 | .pull-right; 33 | font-size: @font-size-small; 34 | } 35 | } 36 | .room-list { 37 | .list-unstyled; 38 | 39 | .room { 40 | cursor: pointer; 41 | 42 | &:hover { 43 | background-color: @gray-lighter; 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /static/js/components/__tests__/ChatRoom-test.react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | import { expect } from 'chai'; 4 | 5 | import { sampleMessages } from './utils'; 6 | 7 | import Author from '../Author.react'; 8 | import ChatRoom from '../ChatRoom.react'; 9 | import MessageList from '../MessageList.react'; 10 | 11 | 12 | describe('', () => { 13 | it('renders room name', () => { 14 | const room = { id: 10, name: 'ted' }; 15 | const wrapper = shallow( 16 | 17 | ); 18 | expect(wrapper.find('.room-name').text()).to.equal('@ted'); 19 | }); 20 | 21 | it('renders and ', () => { 22 | const wrapper = mount( 23 | 24 | ); 25 | expect(wrapper.find(Author)).to.have.length(1); 26 | expect(wrapper.find(MessageList)).to.have.length(1); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /apps/chat/tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | 4 | class UserFactory(factory.django.DjangoModelFactory): 5 | class Meta: 6 | model = 'chat.User' 7 | 8 | username = 'alice' 9 | 10 | 11 | class RoomFactory(factory.django.DjangoModelFactory): 12 | class Meta: 13 | model = 'chat.Room' 14 | 15 | @factory.post_generation 16 | def users(self, create, extracted, **kwargs): 17 | if not create: 18 | # Simple build, do nothing. 19 | return 20 | 21 | if extracted: 22 | # A list of groups were passed in, use them 23 | for user in extracted: 24 | self.users.add(user) 25 | 26 | 27 | class MessageFactory(factory.django.DjangoModelFactory): 28 | class Meta: 29 | model = 'chat.Message' 30 | 31 | room = factory.SubFactory(RoomFactory) 32 | user = factory.SubFactory(UserFactory) 33 | content = 'Cause I had something to do, something to say' 34 | -------------------------------------------------------------------------------- /static/js/components/Author.react.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const Author = ({ onSendMessage }) => { 4 | let input; 5 | 6 | const handleSendMessage = (e) => { 7 | e.preventDefault(); 8 | if (!input.value.trim()) { 9 | return; 10 | } 11 | 12 | onSendMessage(input.value); 13 | 14 | // clear out the text box 15 | input.value = ''; 16 | }; 17 | 18 | const setInput = (node) => { 19 | input = node; 20 | }; 21 | 22 | return ( 23 |
    24 |
    25 | 26 | 27 | 28 | 29 |
    30 |
    31 | ); 32 | }; 33 | 34 | Author.propTypes = { 35 | onSendMessage: PropTypes.func.isRequired, 36 | }; 37 | 38 | 39 | export default Author; 40 | -------------------------------------------------------------------------------- /apps/chat/tests/test_consumers.py: -------------------------------------------------------------------------------- 1 | from channels.message import Message 2 | from django.test import TestCase 3 | from django.test.utils import override_settings 4 | from unittest.mock import patch 5 | 6 | from chat.consumers import ws_disconnect, ws_message 7 | 8 | 9 | @override_settings(CHANNEL_LAYERS={'default': { 10 | 'BACKEND': 'asgiref.inmemory.ChannelLayer', 11 | 'ROUTING': 'project.routing.channel_routing', 12 | }}) 13 | class ConsumersTest(TestCase): 14 | @patch('chat.consumers.ChatEngine') 15 | def test_ws_message(self, engine): 16 | message = Message({'reply_channel': 'test-reply'}, None, None) 17 | ws_message(message) 18 | 19 | engine.dispatch.assert_called_with(message) 20 | 21 | @patch('chat.consumers.ChatEngine') 22 | def test_ws_disconnect(self, engine): 23 | message = Message({'reply_channel': 'test-reply'}, None, None) 24 | ws_disconnect(message) 25 | 26 | engine.assert_called_with(message) 27 | engine.disconnect.assert_called() 28 | -------------------------------------------------------------------------------- /static/js/index.react.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import React from 'react'; 3 | import { render } from 'react-dom'; 4 | import { Provider } from 'react-redux'; 5 | import { createStore, applyMiddleware } from 'redux'; 6 | import thunkMiddleware from 'redux-thunk'; 7 | import createLogger from 'redux-logger'; 8 | import reducer from './reducers'; 9 | import Root from './containers/Root.react'; 10 | 11 | import { ChatAPI } from './utils/ChatAPI'; 12 | 13 | const loggerMiddleware = createLogger(); 14 | 15 | const store = createStore( 16 | reducer, 17 | applyMiddleware( 18 | thunkMiddleware, 19 | loggerMiddleware 20 | ) 21 | ); 22 | 23 | 24 | // Under mocha test? via http://stackoverflow.com/a/29183140/82872 25 | // TODO Undo 26 | const isInTest = typeof global.it === 'function'; 27 | 28 | if (!isInTest) { 29 | ChatAPI.connect(); 30 | ChatAPI.listen(store); 31 | 32 | render( 33 | 34 | 35 | , 36 | document.getElementById('root') 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /static/js/containers/VisibleChatRoom.react.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { requestPriorMessages, sendMessage } from '../actions'; 3 | import ChatRoom from '../components/ChatRoom.react'; 4 | 5 | const getVisibleMessages = (messages, roomId) => ( 6 | messages.filter( 7 | m => m.roomId === roomId 8 | ) 9 | ); 10 | 11 | const getRoom = (rooms, roomId) => ( 12 | rooms.find(r => r.id === roomId) 13 | ); 14 | 15 | const mapStateToProps = (state) => ({ 16 | room: getRoom(state.rooms, state.currentRoomId), 17 | messages: getVisibleMessages(state.messages, state.currentRoomId), 18 | }); 19 | 20 | const mapDispatchToProps = (dispatch) => ({ 21 | handleSendMessage: (room, content) => { 22 | dispatch(sendMessage(room, content)); 23 | }, 24 | handleMessageScroll: (room, messages) => { 25 | dispatch(requestPriorMessages(room, messages)); 26 | }, 27 | }); 28 | 29 | const VisibleChatRoom = connect( 30 | mapStateToProps, 31 | mapDispatchToProps 32 | )(ChatRoom); 33 | 34 | export default VisibleChatRoom; 35 | -------------------------------------------------------------------------------- /static/js/containers/Root.react.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { loginUser } from '../actions'; 4 | import Login from '../components/Login.react'; 5 | import ChatApp from '../components/ChatApp.react'; 6 | 7 | 8 | class Root extends React.Component { 9 | 10 | render() { 11 | const content = this.props.currentUser === null ? : ; 12 | return ( 13 |
    14 | {content} 15 |
    16 | ); 17 | } 18 | } 19 | 20 | Root.propTypes = { 21 | currentUser: PropTypes.string, 22 | handleUserChange: PropTypes.func.isRequired, 23 | }; 24 | 25 | 26 | const mapStateToProps = (state) => ({ 27 | currentUser: state.currentUser, 28 | }); 29 | 30 | const mapDispatchToProps = (dispatch) => ({ 31 | handleUserChange: (user) => { 32 | dispatch(loginUser(user)); 33 | }, 34 | }); 35 | 36 | export default connect( 37 | mapStateToProps, 38 | mapDispatchToProps 39 | )(Root); 40 | -------------------------------------------------------------------------------- /static/js/components/Login.react.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | 3 | const Login = ({ onUserChange }) => { 4 | let input; 5 | 6 | const handleUserChange = (e) => { 7 | e.preventDefault(); 8 | if (!input.value.trim()) { 9 | return; 10 | } 11 | 12 | onUserChange(input.value); 13 | 14 | // clear out the text box 15 | input.value = ''; 16 | }; 17 | 18 | const setInput = (node) => { 19 | input = node; 20 | }; 21 | 22 | return ( 23 |
    24 |
    25 |
    26 |
    27 | 28 | 29 | 30 | 31 |
    32 |
    33 |
    34 |
    35 | ); 36 | }; 37 | 38 | 39 | Login.propTypes = { 40 | onUserChange: PropTypes.func.isRequired, 41 | }; 42 | 43 | export default Login; 44 | -------------------------------------------------------------------------------- /static/js/components/ChatRoom.react.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import MessageList from './MessageList.react'; 3 | import Author from './Author.react'; 4 | 5 | 6 | class ChatRoom extends React.Component { 7 | constructor() { 8 | super(); 9 | this._handleSendMessage = this._handleSendMessage.bind(this); 10 | } 11 | 12 | _handleSendMessage(content) { 13 | return this.props.handleSendMessage(this.props.room.id, content); 14 | } 15 | 16 | render() { 17 | if (typeof this.props.room === 'undefined') { 18 | return
    Select Room
    ; 19 | } 20 | return ( 21 |
    22 |

    @{this.props.room.name}

    23 | 25 | 26 |
    27 | ); 28 | } 29 | } 30 | 31 | ChatRoom.propTypes = { 32 | room: PropTypes.shape({ 33 | id: PropTypes.number.isRequired, 34 | name: PropTypes.string.isRequired, 35 | }), 36 | messages: PropTypes.array, 37 | handleSendMessage: PropTypes.func.isRequired, 38 | handleMessageScroll: PropTypes.func.isRequired, 39 | }; 40 | 41 | export default ChatRoom; 42 | -------------------------------------------------------------------------------- /apps/chat/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from django.test import TestCase 3 | 4 | from .factories import MessageFactory, RoomFactory, UserFactory 5 | 6 | import pytz 7 | 8 | 9 | class UserTest(TestCase): 10 | def test_str(self): 11 | u = UserFactory.create() 12 | self.assertEqual(str(u), 'alice') 13 | 14 | 15 | class RoomTest(TestCase): 16 | def test_name(self): 17 | r = RoomFactory.create(users=[ 18 | UserFactory.create(username='alice'), 19 | UserFactory.create(username='bob'), 20 | ]) 21 | self.assertEqual(r.name('alice'), 'bob') 22 | 23 | def test_str(self): 24 | r = RoomFactory.create(users=[ 25 | UserFactory.create(username='alice'), 26 | UserFactory.create(username='bob'), 27 | ]) 28 | self.assertEqual(str(r), 'alice-bob') 29 | 30 | def test_str_empty(self): 31 | # Shouldn't happen, just ensure no 500 32 | r = RoomFactory.create() 33 | self.assertEqual(str(r), '') 34 | 35 | 36 | class MessageTest(TestCase): 37 | def test_str(self): 38 | m = MessageFactory.create( 39 | timestamp=datetime(2016, 7, 4, 5, 27, 30, tzinfo=pytz.utc) 40 | ) 41 | self.assertEqual(str(m), 'alice at 2016-07-04 05:27:30+00:00') 42 | -------------------------------------------------------------------------------- /apps/chat/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load staticfiles %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | Channel Chat 9 | 10 | 11 | 12 | 13 | 14 | 27 | 28 | {% block content %}{% endblock %} 29 | 30 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /apps/chat/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | 4 | 5 | class User(models.Model): 6 | # We implement a basic user model (we are ignoring authentication 7 | # at the moment, so anyone can "log" in as anyone. We should 8 | # switch to use django.contrib.auth.models.User 9 | username = models.TextField() 10 | 11 | def __str__(self): 12 | return self.username 13 | 14 | 15 | class Room(models.Model): 16 | # While we are only concerned about user-to-user direct messages, 17 | # we model more generally to allow future multi-user rooms 18 | users = models.ManyToManyField(User) 19 | 20 | # TODO Store an actual room name, but for now we'll cheat and just 21 | # call it the name of the other user 22 | def name(self, current_username): 23 | return str(self.users.exclude(username=current_username)[0]) 24 | 25 | def __str__(self): 26 | return '-'.join( 27 | x[0] for x in self.users.order_by('id').values_list('username') 28 | ) 29 | 30 | 31 | class Message(models.Model): 32 | room = models.ForeignKey(Room, related_name='messages') 33 | user = models.ForeignKey(User) 34 | timestamp = models.DateTimeField(db_index=True, default=timezone.now) 35 | content = models.TextField() 36 | 37 | def __str__(self): 38 | return '{0} at {1}'.format(self.user, self.timestamp) 39 | -------------------------------------------------------------------------------- /static/js/actions.js: -------------------------------------------------------------------------------- 1 | import ActionTypes from './constants'; 2 | import { ChatAPI } from './utils/ChatAPI'; 3 | import _ from 'lodash'; 4 | 5 | 6 | export function loginUser(user) { 7 | return () => { 8 | ChatAPI.send({ 9 | type: ActionTypes.LOGIN, 10 | user, 11 | }); 12 | }; 13 | } 14 | 15 | export function requestMessages(room) { 16 | // TODO Don't need to do this if room's messages exist in state. 17 | ChatAPI.send({ 18 | type: ActionTypes.REQUEST_MESSAGES, 19 | roomId: room.id, 20 | }); 21 | } 22 | 23 | export function requestPriorMessages(room, messages) { 24 | return () => { 25 | const firstMessage = _.minBy(messages, (m) => m.id); 26 | ChatAPI.send({ 27 | type: ActionTypes.REQUEST_MESSAGES, 28 | firstMessageId: firstMessage.id, 29 | roomId: room.id, 30 | }); 31 | }; 32 | } 33 | 34 | export function selectRoom(room) { 35 | return (dispatch) => { 36 | // Ask the server for the messages for the current room. 37 | requestMessages(room); 38 | 39 | // Re-dispatch so the state gets a new currenRoomId 40 | dispatch({ 41 | type: ActionTypes.SELECT_ROOM, 42 | room, 43 | }); 44 | }; 45 | } 46 | 47 | // thunk returns a function for evaluation by middleware 48 | export function sendMessage(roomId, content) { 49 | return () => { 50 | ChatAPI.send({ 51 | type: ActionTypes.SEND_MESSAGE, 52 | roomId, 53 | content, 54 | }); 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, John Paulett (john -at- paulett.org) 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /static/js/components/__tests__/Login-test.react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | import { expect } from 'chai'; 4 | import sinon from 'sinon'; 5 | 6 | import Login from '../Login.react'; 7 | 8 | describe('', () => { 9 | it('renders form', () => { 10 | const onUserChange = sinon.spy(); 11 | const wrapper = shallow( 12 | 13 | ); 14 | 15 | expect(wrapper.find('input').hasClass('form-control')).to.equal(true); 16 | }); 17 | 18 | it('handles on submit and clears text', () => { 19 | const onUserChange = sinon.spy(); 20 | // use mount() to expose the ref input 21 | const wrapper = mount( 22 | 23 | ); 24 | 25 | const input = wrapper.find('input'); 26 | input.node.value = 'Hello bob'; 27 | input.simulate('change', input); 28 | 29 | wrapper.find('form').simulate('submit'); 30 | // handler called 31 | expect(onUserChange).to.have.property('callCount', 1); 32 | expect(onUserChange.calledWith('Hello bob')).to.be.equal(true); 33 | 34 | // value cleared out 35 | expect(input.get(0).value).to.equal(''); 36 | }); 37 | 38 | it('ignores empty input', () => { 39 | const onUserChange = sinon.spy(); 40 | // use mount() to expose the ref input 41 | const wrapper = mount( 42 | 43 | ); 44 | 45 | wrapper.find('form').simulate('submit'); 46 | expect(onUserChange).to.have.property('callCount', 0); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /static/js/components/__tests__/Author-test.react.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | import { expect } from 'chai'; 4 | import sinon from 'sinon'; 5 | 6 | import Author from '../Author.react'; 7 | 8 | describe('', () => { 9 | it('renders form', () => { 10 | const onSendMessage = sinon.spy(); 11 | const wrapper = shallow( 12 | 13 | ); 14 | 15 | expect(wrapper.find('input').hasClass('form-control')).to.equal(true); 16 | }); 17 | 18 | it('handles on submit and clears text', () => { 19 | const onSendMessage = sinon.spy(); 20 | // use mount() to expose the ref input 21 | const wrapper = mount( 22 | 23 | ); 24 | 25 | const input = wrapper.find('input'); 26 | input.node.value = 'Hello bob'; 27 | input.simulate('change', input); 28 | 29 | wrapper.find('form').simulate('submit'); 30 | // handler called 31 | expect(onSendMessage).to.have.property('callCount', 1); 32 | expect(onSendMessage.calledWith('Hello bob')).to.be.equal(true); 33 | 34 | // value cleared out 35 | expect(input.get(0).value).to.equal(''); 36 | }); 37 | 38 | it('ignores empty input', () => { 39 | const onSendMessage = sinon.spy(); 40 | // use mount() to expose the ref input 41 | const wrapper = mount( 42 | 43 | ); 44 | 45 | wrapper.find('form').simulate('submit'); 46 | expect(onSendMessage).to.have.property('callCount', 0); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "channel_chat", 3 | "version": "0.0.1", 4 | "description": "channel_chat", 5 | "main": "static/js/index.react.js", 6 | "dependencies": { 7 | "babel-polyfill": "^6.9.1", 8 | "bootstrap": "^3.3.6", 9 | "dateformat": "^1.0.12", 10 | "keymirror": "~0.1.1", 11 | "lodash": "^4.13.1", 12 | "react": "^15.2.1", 13 | "react-dom": "^15.2.1", 14 | "react-redux": "^4.4.5", 15 | "redux": "^3.5.2", 16 | "redux-logger": "^2.6.1", 17 | "redux-thunk": "^2.1.0", 18 | "shopify-reconnecting-websocket": "^0.1.0", 19 | "sinon": "^1.17.4" 20 | }, 21 | "devDependencies": { 22 | "babel-preset-es2015": "^6.9.0", 23 | "babel-preset-react": "^6.11.1", 24 | "babelify": "^7.3.0", 25 | "browserify": "^13.0.1", 26 | "chai": "^3.5.0", 27 | "envify": "^3.4.1", 28 | "enzyme": "^2.4.1", 29 | "eslint": "^2.9.0", 30 | "eslint-config-airbnb": "^9.0.1", 31 | "eslint-plugin-import": "^1.10.2", 32 | "eslint-plugin-jsx-a11y": "^1.2.0", 33 | "eslint-plugin-react": "^5.2.2", 34 | "jsdom": "^9.4.1", 35 | "less": "^2.7.1", 36 | "less-plugin-clean-css": "^1.5.1", 37 | "mocha": "^2.5.3", 38 | "react-addons-test-utils": "^15.2.1", 39 | "redux-mock-store": "^1.1.2", 40 | "uglifyjs": "^2.4.10", 41 | "watchify": "^3.7.0" 42 | }, 43 | "scripts": { 44 | "start": "watchify -o static/bundle.js -v -d .", 45 | "build": "NODE_ENV=production browserify . | uglifyjs -cm > static/bundle.js", 46 | "less": "lessc --clean-css static/less/base.less static/style.min.css", 47 | "lint": "eslint static/js", 48 | "test": "mocha --compilers js:babel-register --require static/js/__tests__/setup.js --recursive static/js", 49 | "test:watch": "npm test -- --watch" 50 | }, 51 | "browserify": { 52 | "transform": [ 53 | "babelify", 54 | "envify" 55 | ] 56 | }, 57 | "author": "John Paulett ", 58 | "license": "BSD", 59 | "private": true 60 | } 61 | -------------------------------------------------------------------------------- /apps/chat/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.7 on 2016-07-13 23:46 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import django.utils.timezone 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Message', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('timestamp', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), 23 | ('content', models.TextField()), 24 | ], 25 | ), 26 | migrations.CreateModel( 27 | name='Room', 28 | fields=[ 29 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 30 | ], 31 | ), 32 | migrations.CreateModel( 33 | name='User', 34 | fields=[ 35 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 36 | ('username', models.TextField()), 37 | ], 38 | ), 39 | migrations.AddField( 40 | model_name='room', 41 | name='users', 42 | field=models.ManyToManyField(to='chat.User'), 43 | ), 44 | migrations.AddField( 45 | model_name='message', 46 | name='room', 47 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='chat.Room'), 48 | ), 49 | migrations.AddField( 50 | model_name='message', 51 | name='user', 52 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='chat.User'), 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /apps/chat/engine/base.py: -------------------------------------------------------------------------------- 1 | from channels import Group 2 | 3 | import json 4 | 5 | 6 | class ActionEngine(object): 7 | """A simple dispatcher that consumes a Redux-style action and routes 8 | it to a method on the subclass, using the `action.type`. 9 | 10 | E.g. If an action comes in {type: 'login', user: 'bob'}, it will 11 | call the `LOGIN` method, passing in the the asgi message and parsed 12 | action. 13 | 14 | This is a very simplistic router and likely not ideal for longer-term 15 | since it ties the React client-side actions, to the network procedure 16 | calling protocol, to the server-side method definition. It also 17 | effectively exposes the Python methods to the client which could 18 | be a security risk, though we do mitigate by uppercasing the requested 19 | method which so not expose protected methods. 20 | 21 | Callers should use the `ActionEngine.dispath(message)`. Subclasses 22 | can use the `add` and `send` methods. 23 | """ 24 | @classmethod 25 | def dispatch(cls, message): 26 | engine = cls(message) 27 | 28 | # Parse the websocket message into a JSON action 29 | action = json.loads(message.content['text']) 30 | 31 | # Simple protection to only expose upper case methods 32 | # to client-side directives 33 | action_type = action['type'].upper() 34 | if hasattr(engine, action_type): 35 | method = getattr(engine, action_type) 36 | return method(action) 37 | else: 38 | raise NotImplementedError('{} not implemented'.format(action_type)) 39 | 40 | def __init__(self, message): 41 | self.message = message 42 | 43 | def add(self, group): 44 | Group(group).add(self.message.reply_channel) 45 | 46 | def send(self, action, to=None): 47 | if to is None: 48 | to = self.message.reply_channel 49 | to.send({ 50 | 'text': json.dumps(action), 51 | }) 52 | 53 | def send_to_group(self, group, action): 54 | self.send(action, to=Group(group)) 55 | -------------------------------------------------------------------------------- /static/js/reducers.js: -------------------------------------------------------------------------------- 1 | import ActionTypes from './constants'; 2 | import _ from 'lodash'; 3 | 4 | 5 | const initialState = { 6 | currentUser: null, 7 | currentRoomId: null, 8 | rooms: [ 9 | /* { id: 1, name: 'alice', active: true }, 10 | { id: 2, name: 'bob', active: true }, 11 | { id: 3, name: 'eve', active: false }, 12 | { id: 4, name: 'grace', active: false }, 13 | */ 14 | ], 15 | messages: [ 16 | /* 17 | { id: 1, content: 'hello world', user: 'alice', roomId: 1, 18 | timestamp: Date.now()/1000 }, 19 | { id: 2, content: 'Hey!', user: 'bob', roomId: 1, 20 | timestamp: Date.now()/1000 }, 21 | { id: 3, content: 'Welcome Bob', user: 'bob', roomId: 4, 22 | timestamp: Date.now()/1000 }, 23 | */ 24 | ], 25 | }; 26 | 27 | 28 | function reducer(state = initialState, action) { 29 | switch (action.type) { 30 | case ActionTypes.RECEIVE_MESSAGES: 31 | // Ensure no duplicate messages 32 | const messages = _.unionWith( 33 | state.messages, 34 | action.messages, 35 | (x, y) => x.id === y.id 36 | ); 37 | // Enforce sort order (future operations assume ASC order of 38 | // state.messages) 39 | // TODO Consider a smarter algorithm than _.unionWith().sort(), 40 | // perhaps a Merge Sort since state.messages will already be sorted. 41 | // Insertion Sort is what we likely want (thanks Jason) 42 | messages.sort( 43 | (x, y) => x.id - y.id // x.timestamp - y.timestamp 44 | ); 45 | 46 | return Object.assign({}, state, { 47 | messages, 48 | }); 49 | 50 | case ActionTypes.RECEIVE_ROOMS: 51 | return Object.assign({}, state, { 52 | rooms: action.rooms, 53 | }); 54 | 55 | case ActionTypes.SELECT_ROOM: 56 | return Object.assign({}, state, { 57 | currentRoomId: action.room.id, 58 | }); 59 | 60 | case ActionTypes.LOGIN_SUCCESS: 61 | return Object.assign({}, state, { 62 | currentUser: action.user, 63 | }); 64 | default: 65 | return state; 66 | } 67 | } 68 | 69 | export default reducer; 70 | -------------------------------------------------------------------------------- /static/js/components/MessageList.react.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { findDOMNode } from 'react-dom'; 3 | import Message from './Message.react'; 4 | 5 | class MessageList extends React.Component { 6 | constructor() { 7 | super(); 8 | this._handleScroll = this._handleScroll.bind(this); 9 | } 10 | 11 | componentWillUpdate() { 12 | // Before we re-render, see if the user manually scrolled back, we do not 13 | // want to force them back down to the bottom 14 | const node = findDOMNode(this); 15 | this.shouldScrollBottom = ( 16 | (node.scrollTop + node.offsetHeight) === node.scrollHeight 17 | ); 18 | } 19 | 20 | componentDidUpdate() { 21 | if (this.shouldScrollBottom) { 22 | // Stay scrolled at the bottom since new messages will be appended 23 | const node = findDOMNode(this); 24 | node.scrollTop = node.scrollHeight; 25 | } 26 | } 27 | 28 | _handleScroll(e) { 29 | const node = findDOMNode(this); 30 | 31 | // scrollTop == 0 indicates element not scrollable. We start paging 32 | // when <100 (magically picked) from the top 33 | if (node.scrollTop > 0 && node.scrollTop == 1) { // < 100 34 | // TODO Don't handle if already requesting 35 | // console.log(node.scrollHeight, node.scrollTop, node.offsetHeight); 36 | //console.log(this.props) 37 | this.props.handleMessageScroll(this.props.room, this.props.messages); 38 | } 39 | } 40 | 41 | render() { 42 | return ( 43 |
    44 | {this.props.messages.map(message => 45 | 46 | )} 47 |
    48 | ); 49 | } 50 | } 51 | 52 | MessageList.propTypes = { 53 | messages: PropTypes.arrayOf(PropTypes.shape({ 54 | id: PropTypes.number.isRequired, 55 | roomId: PropTypes.number.isRequired, 56 | user: PropTypes.string.isRequired, 57 | content: PropTypes.string.isRequired, 58 | timestamp: PropTypes.number.isRequired, 59 | }).isRequired).isRequired, 60 | room: PropTypes.shape({ 61 | id: PropTypes.number.isRequired, 62 | name: PropTypes.string.isRequired, 63 | }), 64 | handleMessageScroll: PropTypes.func.isRequired, 65 | }; 66 | 67 | export default MessageList; 68 | -------------------------------------------------------------------------------- /static/js/__tests__/actions-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import sinon from 'sinon'; 3 | import configureStore from 'redux-mock-store'; 4 | import thunkMiddleware from 'redux-thunk'; 5 | 6 | import * as actions from '../actions'; 7 | import ActionTypes from '../constants'; 8 | 9 | import { ChatAPI } from '../utils/ChatAPI'; 10 | 11 | 12 | describe('async action creators', () => { 13 | const mockStore = configureStore([thunkMiddleware]); 14 | 15 | let sendStub; 16 | beforeEach(() => { 17 | sendStub = sinon.stub(ChatAPI, 'send'); 18 | }); 19 | 20 | afterEach(() => { 21 | sendStub.restore(); 22 | }); 23 | 24 | describe('login', () => { 25 | it('should send to API', () => { 26 | const user = 'grace'; 27 | const expectedApi = { 28 | type: ActionTypes.LOGIN, 29 | user, 30 | }; 31 | 32 | actions.loginUser(user)(); 33 | 34 | expect(sendStub.calledWith(expectedApi)).to.be.equal(true); 35 | }); 36 | }); 37 | 38 | describe('select room', () => { 39 | it('should select room', () => { 40 | const room = { name: 'alice', id: 5 }; 41 | const expectedAction = { 42 | type: ActionTypes.SELECT_ROOM, 43 | room, 44 | }; 45 | const expectedApi = { 46 | type: ActionTypes.REQUEST_MESSAGES, 47 | roomId: room.id, 48 | }; 49 | const store = mockStore({}); 50 | 51 | 52 | actions.selectRoom(room)(store.dispatch); 53 | 54 | expect(sendStub.calledWith(expectedApi)).to.be.equal(true); 55 | 56 | expect(store.getActions()).to.deep.equal([expectedAction]); 57 | }); 58 | }); 59 | 60 | describe('send message', () => { 61 | it('should send to API', () => { 62 | const roomId = 25; 63 | const content = 'hi bob'; 64 | const expectedApi = { 65 | type: ActionTypes.SEND_MESSAGE, 66 | roomId, 67 | content, 68 | }; 69 | 70 | actions.sendMessage(roomId, content)(); 71 | 72 | expect(sendStub.calledWith(expectedApi)).to.be.equal(true); 73 | }); 74 | }); 75 | 76 | describe('request messages', () => { 77 | it('should send to API', () => { 78 | const room = { name: 'alice', id: 5 }; 79 | const expectedApi = { 80 | type: ActionTypes.REQUEST_MESSAGES, 81 | roomId: 5, 82 | }; 83 | 84 | actions.requestMessages(room); 85 | 86 | expect(sendStub.calledWith(expectedApi)).to.be.equal(true); 87 | }); 88 | }); 89 | 90 | describe('request prior messages', () => { 91 | it('should send firstMessageId to API', () => { 92 | const room = { name: 'alice', id: 5 }; 93 | const messages = [ 94 | { id: 6 }, 95 | { id: 7 }, 96 | { id: 4 }, 97 | { id: 8 }, 98 | ]; 99 | 100 | const expectedApi = { 101 | type: ActionTypes.REQUEST_MESSAGES, 102 | roomId: 5, 103 | firstMessageId: 4, 104 | }; 105 | 106 | actions.requestPriorMessages(room, messages)(); 107 | 108 | expect(sendStub.calledWith(expectedApi)).to.be.equal(true); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /apps/chat/tests/test_engine/test_chat.py: -------------------------------------------------------------------------------- 1 | from asgiref.inmemory import ChannelLayer 2 | from channels import Channel, Group 3 | from channels.asgi import get_channel_layer 4 | from channels.handler import AsgiRequest 5 | from channels.message import Message 6 | from django.contrib.sessions.backends.file import SessionStore 7 | from django.test import TestCase 8 | from django.test.utils import override_settings 9 | from unittest.mock import Mock 10 | 11 | from chat.engine import ChatEngine 12 | from chat.models import Message, Room, User 13 | 14 | import json 15 | 16 | 17 | class MessageStub(object): 18 | """Minimal stub for replicating `channels.message.Message`""" 19 | def __init__(self): 20 | self.channel_session = {} 21 | self.reply_channel = Channel('foo!123') 22 | 23 | 24 | @override_settings(CHANNEL_LAYERS={'default': { 25 | 'BACKEND': 'asgiref.inmemory.ChannelLayer', 26 | 'ROUTING': 'project.routing.channel_routing', 27 | }}) 28 | class ChatEngineTestCase(TestCase): 29 | def setUp(self): 30 | self.channel_layer = get_channel_layer() 31 | self.channel_layer.flush() 32 | 33 | self.message = MessageStub() 34 | self.engine = ChatEngine(self.message) 35 | 36 | def tearDown(self): 37 | self.channel_layer.flush() 38 | 39 | def assertJsonEqual(self, encoded, expected): 40 | result = json.loads(encoded) 41 | self.assertEqual(result, expected) 42 | 43 | 44 | class LoginTest(ChatEngineTestCase): 45 | def test_new_user(self): 46 | self.engine.LOGIN({'user': 'bob'}) 47 | self.assertEqual('bob', User.objects.get().username) 48 | 49 | def test_existing_user(self): 50 | u = User.objects.create(username='bob') 51 | self.engine.LOGIN({'user': 'bob'}) 52 | self.assertEqual(u.id, User.objects.get().id) 53 | 54 | def test_user_set_into_session(self): 55 | self.engine.LOGIN({'user': 'bob'}) 56 | self.assertEqual(self.message.channel_session['user'], 'bob') 57 | # Ensure message echo'ed back via the user's control channel 58 | 59 | def test_login_success_to_client(self): 60 | self.engine.LOGIN({'user': 'bob'}) 61 | #print(self.channel_layer._groups) 62 | #print(self.channel_layer._channels) 63 | self.assertJsonEqual( 64 | self.channel_layer._channels[str(self.message.reply_channel)].popleft()[1]['text'], 65 | {'type': 'LOGIN_SUCCESS', 'user': 'bob'} 66 | ) 67 | 68 | def test_rooms_created(self): 69 | pass#TODO 70 | 71 | def test_receive_rooms(self): 72 | pass#TODO 73 | 74 | 75 | class SendMessageTest(ChatEngineTestCase): 76 | def setUp(self): 77 | super().setUp() 78 | 79 | User.objects.create(username='alice') 80 | 81 | self.engine.LOGIN({'user': 'bob'}) 82 | self.room = Room.objects.get() 83 | 84 | def test(self): 85 | self.engine.SEND_MESSAGE({ 86 | 'roomId': self.room.id, 87 | 'content': 'hello world', 88 | }) 89 | 90 | m = Message.objects.get() 91 | self.assertEqual('hello world', m.content) 92 | self.assertEqual('bob', m.user.username) 93 | self.assertEqual(self.room, m.room) 94 | 95 | # Ensure we get a RECEIVE_MESSAGES back 96 | print(self.channel_layer._channels) 97 | -------------------------------------------------------------------------------- /static/js/utils/ChatAPI.js: -------------------------------------------------------------------------------- 1 | import ReconnectingWebSocket from 'shopify-reconnecting-websocket'; 2 | import ActionTypes from '../constants'; 3 | import _ from 'lodash'; 4 | import { loginUser, selectRoom } from '../actions'; 5 | 6 | 7 | const receiveSocketMessage = (store, action) => { 8 | /* We cheat by using the Redux-style Actions as our 9 | * communication protocol with the server. This hack allows 10 | * the server to directly act as a Action Creator, which we 11 | * simply `dispatch()`. Consider separating communication format 12 | * from client-side action API. 13 | */ 14 | switch (action.type) { 15 | // TODO Single Message Notification 16 | /* 17 | case ActionTypes.RECEIVE_MESSAGE: 18 | if ('Notification' in window) { 19 | Notification.requestPermission().then(function(permission) { 20 | if (permission === 'granted') { 21 | const n = new Notification(message.room, { 22 | body: message.content, 23 | }); 24 | n.onclick(function(event){ 25 | // event.preventDefault(); 26 | // open the room that contains this message 27 | }); 28 | setTimeout(n.close.bind(n), 3000); 29 | } 30 | }); 31 | ... continue to dispatch() */ 32 | case ActionTypes.RECEIVE_ROOMS: 33 | store.dispatch(action); 34 | 35 | // For the intial state, just open the first chat room. 36 | // TODO Should be the last-opened room (via Cookie, server, or max ID) 37 | const state = store.getState(); 38 | const rooms = action.rooms; 39 | if (state.currentRoomId === null && rooms.length > 0) { 40 | selectRoom(rooms[0])(store.dispatch); 41 | } 42 | break; 43 | case ActionTypes.RECEIVE_MESSAGES: 44 | default: 45 | return store.dispatch(action); 46 | } 47 | }; 48 | 49 | const reconnect = (state) => { 50 | // Re-login (need user on channel_session) 51 | loginUser(state.currentUser)(); 52 | 53 | // TODO Delay the REQUEST_MESSAGES until after the LOGIN returns 54 | // Ensure we did not miss any messages 55 | const lastMessage = _.maxBy(state.messages, (m) => m.id); 56 | ChatAPI.send({ 57 | type: ActionTypes.REQUEST_MESSAGES, 58 | lastMessageId: typeof lastMessage === 'undefined' ? 0 : lastMessage.id, 59 | user: state.currentUser, 60 | }); 61 | }; 62 | 63 | 64 | // TODO Consider re-implementing ChatAPI as a class, instead of using a 65 | // module-level global 66 | // FIXME on error / reconnect 67 | let _socket = null; 68 | 69 | export const ChatAPI = { 70 | connect: () => { 71 | // Use wss:// if running on https:// 72 | const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; 73 | const url = `${scheme}://${window.location.host}/chat`; 74 | _socket = new ReconnectingWebSocket(url); 75 | }, 76 | 77 | listen: (store) => { 78 | _socket.onmessage = (event) => { 79 | const action = JSON.parse(event.data); 80 | receiveSocketMessage(store, action); 81 | }; 82 | 83 | _socket.onopen = () => { 84 | const state = store.getState(); 85 | 86 | // On Reconnect, need to re-login, so the channel_session['user'] 87 | // is populated 88 | if (state.currentUser !== null) { 89 | reconnect(state); 90 | } 91 | }; 92 | }, 93 | 94 | send: (action) => { 95 | _socket.send(JSON.stringify(action)); 96 | }, 97 | }; 98 | 99 | //const api = new ChatAPI(); 100 | //export default ChatAPI; 101 | -------------------------------------------------------------------------------- /static/js/__tests__/reducers-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import reducer from '../reducers'; 4 | import ActionTypes from '../constants'; 5 | 6 | 7 | describe('reducer', () => { 8 | const initialState = { 9 | currentUser: null, 10 | currentRoomId: null, 11 | rooms: [], 12 | messages: [], 13 | }; 14 | 15 | describe('RECEIVE_MESSAGES', () => { 16 | it('should set messages', () => { 17 | const messages = [ 18 | { id: 5, content: 'a' }, 19 | { id: 7, content: 'b' }, 20 | { id: 8, content: 'c' }, 21 | { id: 6, content: 'd' }, 22 | ]; 23 | const action = { 24 | type: ActionTypes.RECEIVE_MESSAGES, 25 | messages, 26 | }; 27 | 28 | const state = reducer(undefined, action); 29 | expect(state.messages).to.deep.equal(messages); 30 | }); 31 | 32 | it('should merge with existing messages', () => { 33 | const messages = [ 34 | { id: 5, content: 'a' }, 35 | { id: 7, content: 'b' }, 36 | { id: 8, content: 'c' }, 37 | { id: 6, content: 'd' }, 38 | ]; 39 | const action = { 40 | type: ActionTypes.RECEIVE_MESSAGES, 41 | messages, 42 | }; 43 | 44 | initialState.messages = [ 45 | { id: 4, content: 'x' }, 46 | { id: 5, content: 'y' }, 47 | ]; 48 | 49 | const state = reducer(initialState, action); 50 | expect(state.messages).to.deep.equal([ 51 | { id: 4, content: 'x' }, 52 | { id: 5, content: 'y' }, 53 | { id: 7, content: 'b' }, 54 | { id: 8, content: 'c' }, 55 | { id: 6, content: 'd' }, 56 | ]); 57 | }); 58 | }); 59 | 60 | describe('RECEIVE_ROOMS', () => { 61 | it('when unset should set rooms and current room', () => { 62 | const rooms = [ 63 | { id: 5, name: 'a' }, 64 | { id: 7, name: 'b' }, 65 | { id: 8, name: 'c' }, 66 | { id: 6, name: 'd' }, 67 | ]; 68 | const action = { 69 | type: ActionTypes.RECEIVE_ROOMS, 70 | rooms, 71 | }; 72 | 73 | const state = reducer(undefined, action); 74 | expect(state.rooms).to.deep.equal(rooms); 75 | // defaults to first room 76 | expect(state.currentRoomId).to.be.equal(5); 77 | }); 78 | 79 | it('when set should only set rooms', () => { 80 | const rooms = [ 81 | { id: 5, name: 'a' }, 82 | { id: 7, name: 'b' }, 83 | { id: 8, name: 'c' }, 84 | { id: 6, name: 'd' }, 85 | ]; 86 | const action = { 87 | type: ActionTypes.RECEIVE_ROOMS, 88 | rooms, 89 | }; 90 | 91 | initialState.currentRoomId = 8; 92 | 93 | const state = reducer(initialState, action); 94 | expect(state.rooms).to.deep.equal(rooms); 95 | // defaults to first room 96 | expect(state.currentRoomId).to.be.equal(8); 97 | }); 98 | }); 99 | 100 | describe('SELECT_ROOM', () => { 101 | it('should set currentRoomId', () => { 102 | const room = { id: 12, name: 'sue' }; 103 | const action = { 104 | type: ActionTypes.SELECT_ROOM, 105 | room, 106 | }; 107 | 108 | const state = reducer(undefined, action); 109 | expect(state.currentRoomId).to.be.equal(12); 110 | }); 111 | }); 112 | 113 | describe('LOGIN_SUCCESS', () => { 114 | it('should set currentUser', () => { 115 | const action = { 116 | type: ActionTypes.LOGIN_SUCCESS, 117 | user: 'bob', 118 | }; 119 | 120 | const state = reducer(undefined, action); 121 | expect(state.currentUser).to.be.equal('bob'); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /project/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | 6 | 7 | def abspath(*args): 8 | return os.path.abspath(os.path.join(BASE_DIR, *args)) 9 | 10 | # load the apps folder 11 | sys.path.append(abspath('apps')) 12 | 13 | # Set in local_settings.py 14 | SECRET_KEY = None 15 | # SECURITY WARNING: don't run with debug turned on in production! 16 | DEBUG = False 17 | 18 | ALLOWED_HOSTS = [] 19 | 20 | 21 | # Application definition 22 | 23 | INSTALLED_APPS = [ 24 | 'django.contrib.admin', 25 | 'django.contrib.auth', 26 | 'django.contrib.contenttypes', 27 | 'django.contrib.sessions', 28 | 'django.contrib.messages', 29 | 'django.contrib.staticfiles', 30 | 31 | 'channels', 32 | 33 | 'chat', 34 | ] 35 | 36 | MIDDLEWARE_CLASSES = [ 37 | 'django.middleware.security.SecurityMiddleware', 38 | 'django.contrib.sessions.middleware.SessionMiddleware', 39 | 'django.middleware.common.CommonMiddleware', 40 | 'django.middleware.csrf.CsrfViewMiddleware', 41 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 42 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 43 | 'django.contrib.messages.middleware.MessageMiddleware', 44 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 45 | ] 46 | 47 | ROOT_URLCONF = 'project.urls' 48 | 49 | TEMPLATES = [ 50 | { 51 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 52 | 'DIRS': [], 53 | 'APP_DIRS': True, 54 | 'OPTIONS': { 55 | 'context_processors': [ 56 | 'django.template.context_processors.debug', 57 | 'django.template.context_processors.request', 58 | 'django.contrib.auth.context_processors.auth', 59 | 'django.contrib.messages.context_processors.messages', 60 | ], 61 | }, 62 | }, 63 | ] 64 | 65 | WSGI_APPLICATION = 'project.wsgi.application' 66 | 67 | DATABASES = { 68 | 'default': { 69 | # 'django.db.backends.sqlite3' 70 | # 'django.contrib.gis.db.backends.postgis' 71 | 'ENGINE': 'django.db.backends.postgresql', 72 | 'NAME': 'channel_chat', 73 | 'USER': 'channel_chat', 74 | 'PASSWORD': 'channel_chat', 75 | 'HOST': '127.0.0.1', 76 | 'PORT': '5432', 77 | } 78 | } 79 | 80 | 81 | # Password validation 82 | # https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators 83 | 84 | AUTH_PASSWORD_VALIDATORS = [ 85 | { 86 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 87 | }, 88 | { 89 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 90 | }, 91 | { 92 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 93 | }, 94 | { 95 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 96 | }, 97 | ] 98 | 99 | 100 | LANGUAGE_CODE = 'en-us' 101 | USE_I18N = True 102 | USE_L10N = True 103 | TIME_ZONE = 'UTC' 104 | USE_TZ = True 105 | 106 | STATIC_URL = '/static/' 107 | STATICFILES_DIRS = [ 108 | abspath('static'), 109 | # TODO Better loadint 110 | abspath('node_modules/bootstrap/fonts'), 111 | ] 112 | STATIC_ROOT = abspath('collected_static') 113 | 114 | 115 | # channels 116 | CHANNEL_LAYERS = { 117 | 'default': { 118 | 'BACKEND': 'asgi_redis.RedisChannelLayer', 119 | 'CONFIG': { 120 | 'hosts': [('localhost', 6379)], 121 | }, 122 | 'ROUTING': 'project.routing.channel_routing', 123 | }, 124 | } 125 | 126 | try: 127 | # Allow overriding of settings from project/local_settings.py 128 | from .local_settings import * # noqa 129 | except ImportError: 130 | pass 131 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | channels-chat 2 | ============= 3 | 4 | Demonstration of a WebSockets-based chat server, utilizing: 5 | 6 | * `Django Channels `_ 7 | * `React `_ and `Redux `_ 8 | for a reactive client-side application 9 | * Redis as a scalable backend 10 | 11 | 12 | https://github.com/johnpaulett/channel_chat 13 | 14 | .. image:: 15 | https://raw.githubusercontent.com/johnpaulett/channel_chat/master/static/docs/channel-chat.gif 16 | :scale: 50% 17 | 18 | 19 | How it works 20 | ------------ 21 | 22 | A React / Redux frontend application written in ES6, communicates via a 23 | Websocket connection to a Python-based ASGI server. The frontend sends 24 | Redux-style actions as JSON via the Websocket connection, such as 25 | ``"{'type': 'LOGIN_USER', 'username': 'joe'}"``. As implemented by `Django Channels 26 | `_, the ASGI Interface 27 | Server handles the Websocket connection, passing the message onto a Redis queue 28 | for any available Worker to consume the message. The consumers will use the 29 | Redux-style action to delegate to the appropriate ``chat.engine.ChatEngine`` 30 | method (e.g. ``LOGIN_USER``, ``REQUEST_MESSAGES``). The ``ChatEngine`` method 31 | typically will perform some database action (lookup user / messages, store 32 | message) and respond to ``send`` a response back to one or more Channels, 33 | which the Interface server will read back from Redis and communicate back to 34 | the client via the open Websocket connection. The ``ChatEngine`` currently 35 | can send to three types of Channels: 36 | 37 | 1) "Room channels", a ``channels.Group`` representing an ``chat.models.Room``, 38 | in which all clients that are part of that room will receive the message. 39 | 2) The "control channel", a ``channels.Group`` representing all open 40 | socket connections for a specific ``chat.models.User``. 41 | 3) The "reply channel", the specific channel for the current websocket 42 | connection. 43 | 44 | 45 | Supported Actions 46 | ----------------- 47 | 48 | channel_chat is a functional MVP to test out websockets, Channels, and React, 49 | but lacks several key items necessary to make it a production-ready chat 50 | application (see TODO). The currently supported actions are: 51 | 52 | * Connect - Client connection is immediately upgraded from HTTP to Websocket 53 | * Login - User enters handle, ``LOGIN_USER`` sent to server. Server responds with 54 | ``LOGIN_SUCCESS`` (see TODO below) and list of rooms (``RECEIVE_ROOMS``), 55 | pre-building the all the "room channels". 56 | * Send Message - The user can select a Room, enter text and issue a 57 | ``SEND_MESSAGE`` to the server. The server will store the 58 | ``chat.models.Message``, then broadcast that message back out to the entire 59 | "room channel" via the ``RECEIVE_MESSAGES`` action. Clients, upon receiving 60 | the ``RECEIVE_MESSAGES`` action, will update it's Redux state and, if in the 61 | room receiving he message, reactively re-render the ````. 62 | This "room channel" abstraction means that all active websockets will receive 63 | the broadcast, even if the same user is logged in from multiple devices. 64 | * Clients can request message history via ``REQUEST_MESSAGES``, which is 65 | returned via the "reply channel". Several events trigger a message request: 66 | 67 | * When changing rooms, a room ID is passed, and the last 50 messages are sent 68 | (see TODO) 69 | * If the client disconnects and the ``ReconnectingWebsocket`` is able to 70 | reconnect, on reconnect, all messages since the `last message` are requested 71 | and returned. 72 | * When scrolling back into the room's history, the client will request 73 | messages earlier than the first message for that room (see TODO for better 74 | infinite scroll). 75 | 76 | 77 | TODO 78 | ---- 79 | 80 | * Real authentication and then migrate the HTTP session into the Channel session 81 | as part of ``ws_connect``. 82 | * Multi-user rooms. Abstraction already in place for 1 Room to Many Users. 83 | Need to ensure controls around who can send to which Rooms. 84 | * "Add Contact" functionality. Currently we inefficiently pre-create 1-1 rooms 85 | for every user pair. 86 | * Implement Active / Inactive status flag to show who is online (track via 87 | Control-group ping after User hits ``ws_disconnect``). 88 | * Implement an unread message count next to the ````. 89 | * Avoid re-requesting messages for a room when switching between rooms. 90 | * Implement intelligent "infinite scroll" when scrolling back on a room's 91 | history. Basic onScroll has been implemented, but should trigger prior to 92 | ``scrollTop == 0``, should display a loading indicator, and should stay pinned 93 | to location of current messages in view. 94 | * Better workflow for initial post-login (e.g. user's last open room). 95 | * Implement `@handle` parsing and linking. 96 | * Implement the Notification Browser API to provide OS-level alerts on new 97 | messages. 98 | * Provide message search. 99 | 100 | 101 | Running 102 | -------- 103 | 104 | The system has been tested with Ubuntu 14.04, node.js 6.0.3, Python 3.4:: 105 | 106 | git clone https://github.com/johnpaulett/channel_chat 107 | cd channel_chat 108 | 109 | Add :file:`project/local_settings.py`, with a ``SECRET_KEY`` and optionally 110 | ``DEBUG``:: 111 | 112 | SECRET_KEY = 'mysecret' 113 | DEBUG = True 114 | 115 | Continue:: 116 | 117 | nvm use 6 # assumes using nvm to install node.js 6 118 | make env 119 | make static 120 | make createdb 121 | make migrate 122 | make serve 123 | 124 | To run the test suites (mocha and Python unittest):: 125 | 126 | make test 127 | -------------------------------------------------------------------------------- /apps/chat/engine/chat.py: -------------------------------------------------------------------------------- 1 | from channels import Group, DEFAULT_CHANNEL_LAYER, channel_layers 2 | from chat.models import Message, Room, User 3 | from django.db.models import Q 4 | 5 | from . import constants 6 | from .base import ActionEngine 7 | from .utils import timestamp 8 | 9 | 10 | class ChatEngine(ActionEngine): 11 | def get_control_channel(self, user=None): 12 | # Current control channel name, unless told to return `user`'s 13 | # control channel 14 | if user is None: 15 | user = self.message.channel_session['user'] 16 | return 'control.{0}'.format(user) 17 | 18 | def get_room_channel(self, room_id): 19 | return 'room.{0}'.format(room_id) 20 | 21 | def disconnect(self): 22 | # Discard the channel from the control group 23 | Group(self.get_control_channel()).discard( 24 | self.message.reply_channel 25 | ) 26 | 27 | username = self.message.channel_session.get('user') 28 | if username: 29 | user = User.objects.get(username=username) 30 | 31 | # Discard the channel from the all the room groups 32 | for room in Room.objects.filter(users=user): 33 | Group(self.get_room_channel(room.id)).discard( 34 | self.message.reply_channel 35 | ) 36 | # TODO Set rooms to inactive 37 | 38 | def LOGIN(self, action): 39 | # Get or create user and assign to session for future requests 40 | # WARNING: There is NO AUTHENTICATION. Consider moving up to ws_add 41 | username = action['user'] 42 | user, user_created = User.objects.get_or_create(username=username) 43 | self.message.channel_session['user'] = username 44 | 45 | # Add this websocket to the user's control channel group 46 | control = self.get_control_channel() 47 | self.add(control) 48 | 49 | # Echo back the LOGIN to the client 50 | self.send({ 51 | 'type': constants.LOGIN_SUCCESS, 52 | 'user': username 53 | }) 54 | 55 | # Get or create the list of available rooms 56 | # Right now each Room is a 1-1 direct message room, but could easily 57 | # be extended to allow group message rooms. 58 | # WARNING: This is a very dumb and inefficient first-pass approach, 59 | # in which we pre-create a Room for every User-User pair. We should 60 | # instead create rooms on demand or when a user "adds" another user 61 | # to her "friend list" 62 | if user_created: 63 | rooms = [] 64 | for other_user in User.objects.exclude(id=user.id): 65 | room = Room.objects.create() 66 | room.users = [user, other_user] 67 | rooms.append(room) 68 | else: 69 | rooms = [ 70 | room for room in 71 | Room.objects.filter(users=user).distinct() 72 | ] 73 | 74 | # Send the room list back to the user 75 | self.send({ 76 | 'type': constants.RECEIVE_ROOMS, 77 | 'rooms': [ 78 | {'id': room.id, 'name': room.name(user)} 79 | for room in rooms 80 | ], 81 | }) 82 | 83 | # Broadcast the user's joining 84 | for room in rooms: 85 | # Pre-create a room channel 86 | room_channel = self.get_room_channel(room.id) 87 | self.add(room_channel) 88 | 89 | if user_created: 90 | other_user = room.name(user) # FIXME when creating group chats 91 | 92 | # Attach the other users' open socket channels to the room 93 | other_channels = channel_layers[DEFAULT_CHANNEL_LAYER]._group_channels( 94 | self.get_control_channel(other_user) 95 | ) 96 | for channel in other_channels: 97 | Group(room_channel).add(channel) 98 | 99 | # Notify the other users that a new user was created 100 | self.send_to_group(self.get_control_channel(other_user), { 101 | 'type': constants.RECEIVE_ROOMS, 102 | 'rooms': [ 103 | {'id': room.id, 'name': room.name(other_user)} 104 | for room in rooms 105 | ], 106 | }) 107 | 108 | def SEND_MESSAGE(self, action): 109 | username = self.message.channel_session['user'] 110 | 111 | # TODO Check that the user is a member of that room (prevent 112 | # cross posting into rooms she lacks membership too) 113 | room = Room.objects.get(id=action['roomId']) 114 | 115 | user = User.objects.get(username=username) 116 | m = Message.objects.create( 117 | user=user, 118 | room=room, 119 | content=action['content'], 120 | ) 121 | 122 | # Broadcast the message to the room 123 | room_channel = self.get_room_channel(room.id) 124 | self.send_to_group(room_channel, { 125 | 'type': 'RECEIVE_MESSAGES', 126 | 'messages': [{ 127 | 'id': m.id, 128 | 'roomId': room.id, 129 | 'content': m.content, 130 | 'timestamp': timestamp(m.timestamp), 131 | 'user': username, 132 | }], 133 | }) 134 | 135 | def REQUEST_MESSAGES(self, action): 136 | # latest_id, room 137 | 138 | params = Q() 139 | 140 | if 'roomId' in action: 141 | params &= Q(room_id=action['roomId']) 142 | if 'user' in action: 143 | params &= Q(room__users__username=action['user']) 144 | if 'lastMessageId' in action: 145 | # Any messages that occured at or later than time of lastMessage 146 | prior = Message.objects.get(id=action['lastMessageId']) 147 | params &= Q(timestamp__gte=prior.timestamp) 148 | if 'firstMessageId' in action: 149 | # Any messages that occured before the than time of lastMessage 150 | prior = Message.objects.get(id=action['firstMessageId']) 151 | params &= Q(timestamp__lte=prior.timestamp) 152 | 153 | messages = Message.objects.filter( 154 | params 155 | ).select_related( 156 | 'user' 157 | ).order_by( 158 | # Get descending, because of LIMIT, but later reverse order 159 | # in Python to assist browser's sort 160 | '-timestamp', '-id' 161 | )[:50] 162 | 163 | # Reverse since messages displayed ascending 164 | messages = reversed(messages) 165 | 166 | # Return messages to the user 167 | self.send({ 168 | 'type': 'RECEIVE_MESSAGES', 169 | 'messages': [{ 170 | 'id': m.id, 171 | 'roomId': m.room_id, 172 | 'content': m.content, 173 | 'timestamp': timestamp(m.timestamp), 174 | 'user': m.user.username, 175 | } for m in messages], 176 | }) 177 | --------------------------------------------------------------------------------