├── API ├── API │ ├── __init__.py │ ├── wsgi.py │ ├── urls.py │ └── settings.py ├── chat │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0004_auto_20150905_1700.py │ │ ├── 0002_auto_20150731_1920.py │ │ ├── 0005_auto_20160511_1921.py │ │ ├── 0003_auto_20150902_1854.py │ │ └── 0001_squashed_0002_auto_20150707_1647.py │ ├── admin.py │ ├── tests │ │ ├── __init__.py │ │ ├── channel │ │ │ ├── __init__.py │ │ │ ├── test_model.py │ │ │ ├── test_post_view.py │ │ │ └── test_get_view.py │ │ ├── test_url.py │ │ ├── message │ │ │ ├── __init__.py │ │ │ ├── common.py │ │ │ ├── test_delete_view.py │ │ │ ├── test_model.py │ │ │ ├── test_patch_view.py │ │ │ ├── test_post_view.py │ │ │ └── test_get_view.py │ │ ├── common.py │ │ └── test_privileged.py │ ├── utils.py │ ├── urls.py │ ├── models.py │ ├── forms.py │ └── views.py ├── requirements.txt └── manage.py ├── client ├── main.js ├── .babelrc ├── .eslintrc ├── static │ └── sounds │ │ └── message_sound.mp3 ├── config.jsx ├── index.html ├── message │ ├── image.jsx │ ├── content.jsx │ ├── text.jsx │ ├── view.jsx │ ├── __tests__ │ │ └── view-test.js │ ├── form.jsx │ └── history.jsx ├── avatar.jsx ├── locales │ ├── en.json │ └── el.json ├── css │ ├── settings.css │ └── index.css ├── select.jsx ├── topbar.jsx ├── analytics.js ├── webpack.config.js ├── package.json ├── userlist.jsx ├── app.jsx ├── settings.jsx ├── login.jsx └── ting.jsx ├── docker ├── front │ ├── run.sh │ ├── build.sh │ ├── nginx.conf │ ├── supervisord.conf │ └── Dockerfile ├── realtime │ ├── run.sh │ ├── build.sh │ └── Dockerfile ├── api │ ├── create-default-channels.py │ ├── Dockerfile │ └── run.sh └── config │ └── common.json ├── .dockerignore ├── .htaccess ├── etc ├── spec │ ├── chat.jpg │ └── login.jpg ├── mockups │ ├── topbar-mockup │ │ ├── avatar.png │ │ ├── topbar.html │ │ ├── css │ │ │ ├── customstyle.css │ │ │ └── bootstrap-theme.min.css │ │ └── style.css │ ├── login-mockups │ │ ├── js │ │ │ └── .DS_Store │ │ ├── css │ │ │ ├── customstyle.css │ │ │ └── bootstrap-theme.min.css │ │ ├── simple_login.html │ │ ├── password_login.html │ │ └── style.css │ ├── settings-mockup │ │ ├── avatar.png │ │ ├── css │ │ │ ├── customstyle.css │ │ │ └── bootstrap-theme.min.css │ │ ├── style.css │ │ └── settings.html │ ├── ting-channels_files │ │ ├── 0e6be8383b0653c0a36730077cdcfb7f │ │ ├── 3863e7fc8ee24a35326b0a813572bd00 │ │ ├── 8ae1e5b3c4d1df49c17ebefcc25f9dc2 │ │ └── dd6541aa30c336b429c768d4f8e7df2b │ ├── index.css │ └── ting-channels.html ├── credits.txt └── config │ └── systemd │ ├── ting_api.service │ └── ting_realtime.service ├── .gitignore ├── common.yml ├── production.yml ├── .travis.yml ├── config └── common.json ├── realtime ├── package.json ├── tests │ └── server.js └── server.js ├── development.yml ├── .eslintrc ├── LICENSE ├── README.md └── Makefile /API/API/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /API/chat/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /API/chat/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/main.js: -------------------------------------------------------------------------------- 1 | require('./app.jsx'); 2 | -------------------------------------------------------------------------------- /docker/front/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | supervisord 3 | -------------------------------------------------------------------------------- /docker/realtime/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | nodemon server.js 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bower_components 3 | client/dist 4 | -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | Header always unset Content-Security-Policy-Report-Only 2 | -------------------------------------------------------------------------------- /etc/spec/chat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dionyziz/ting/HEAD/etc/spec/chat.jpg -------------------------------------------------------------------------------- /etc/spec/login.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dionyziz/ting/HEAD/etc/spec/login.jpg -------------------------------------------------------------------------------- /API/chat/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /docker/realtime/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export JOBS=MAX 3 | pushd /usr/src/app 4 | npm install 5 | popd 6 | -------------------------------------------------------------------------------- /client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["transform-class-properties"], 3 | "presets": ["env", "react"] 4 | } 5 | -------------------------------------------------------------------------------- /client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "rules": { 4 | "strict": 0 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /etc/mockups/topbar-mockup/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dionyziz/ting/HEAD/etc/mockups/topbar-mockup/avatar.png -------------------------------------------------------------------------------- /client/static/sounds/message_sound.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dionyziz/ting/HEAD/client/static/sounds/message_sound.mp3 -------------------------------------------------------------------------------- /etc/mockups/login-mockups/js/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dionyziz/ting/HEAD/etc/mockups/login-mockups/js/.DS_Store -------------------------------------------------------------------------------- /etc/mockups/settings-mockup/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dionyziz/ting/HEAD/etc/mockups/settings-mockup/avatar.png -------------------------------------------------------------------------------- /API/chat/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from chat.tests.message import * 2 | from chat.tests.channel import * 3 | from chat.tests.test_url import * 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.pyc 3 | *.swp 4 | *.log 5 | *.DS_Store 6 | config/local.json 7 | nohup.out 8 | client/dist/ 9 | API/venv/ 10 | -------------------------------------------------------------------------------- /client/config.jsx: -------------------------------------------------------------------------------- 1 | var config = { 2 | websocket: { 3 | secure: false 4 | }, 5 | port: 8080 6 | }; 7 | 8 | module.exports = config; 9 | -------------------------------------------------------------------------------- /API/requirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.8.3 2 | MySQL-python==1.2.5 3 | argparse==1.2.1 4 | django-dynamic-fixture==1.8.5 5 | pytz==2015.4 6 | six==1.9.0 7 | wsgiref==0.1.2 8 | -------------------------------------------------------------------------------- /docker/api/create-default-channels.py: -------------------------------------------------------------------------------- 1 | from chat.models import Channel 2 | Channel.objects.get_or_create(name='ting') 3 | Channel.objects.get_or_create(name='dev') 4 | -------------------------------------------------------------------------------- /docker/front/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export JOBS=MAX 3 | pushd /usr/src/app 4 | export PATH=$(npm bin):$PATH 5 | npm install 6 | bower --allow-root install 7 | popd 8 | -------------------------------------------------------------------------------- /etc/mockups/ting-channels_files/0e6be8383b0653c0a36730077cdcfb7f: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dionyziz/ting/HEAD/etc/mockups/ting-channels_files/0e6be8383b0653c0a36730077cdcfb7f -------------------------------------------------------------------------------- /etc/mockups/ting-channels_files/3863e7fc8ee24a35326b0a813572bd00: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dionyziz/ting/HEAD/etc/mockups/ting-channels_files/3863e7fc8ee24a35326b0a813572bd00 -------------------------------------------------------------------------------- /etc/mockups/ting-channels_files/8ae1e5b3c4d1df49c17ebefcc25f9dc2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dionyziz/ting/HEAD/etc/mockups/ting-channels_files/8ae1e5b3c4d1df49c17ebefcc25f9dc2 -------------------------------------------------------------------------------- /etc/mockups/ting-channels_files/dd6541aa30c336b429c768d4f8e7df2b: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dionyziz/ting/HEAD/etc/mockups/ting-channels_files/dd6541aa30c336b429c768d4f8e7df2b -------------------------------------------------------------------------------- /API/chat/tests/channel/__init__.py: -------------------------------------------------------------------------------- 1 | from chat.tests.channel.test_get_view import * 2 | from chat.tests.channel.test_post_view import * 3 | from chat.tests.channel.test_model import * 4 | -------------------------------------------------------------------------------- /etc/credits.txt: -------------------------------------------------------------------------------- 1 | Creator: Adam_N 2 | Link: https://www.freesound.org/people/Adam_N/ 3 | 4 | Song's Title: Water_drop_9 5 | Song's Link: https://www.freesound.org/people/Adam_N/sounds/166325/ 6 | -------------------------------------------------------------------------------- /API/chat/tests/test_url.py: -------------------------------------------------------------------------------- 1 | from chat.tests.common import * 2 | 3 | class URLTests(TestCase): 4 | def test_urls(self): 5 | self.assertEqual( 6 | reverse('chat:message', args=('channel', 'foo',)), 7 | '/messages/channel/foo/' 8 | ) 9 | -------------------------------------------------------------------------------- /API/chat/tests/message/__init__.py: -------------------------------------------------------------------------------- 1 | from chat.tests.message.test_post_view import * 2 | from chat.tests.message.test_get_view import * 3 | from chat.tests.message.test_patch_view import * 4 | from chat.tests.message.test_delete_view import * 5 | from chat.tests.message.test_model import * 6 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Ting 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /API/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", "API.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /client/message/image.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'); 2 | 3 | class Image extends React.Component { 4 | render() { 5 | var message_content = this.props.message_content; 6 | 7 | return ( 8 |
9 | image 10 |
11 | ); 12 | } 13 | } 14 | 15 | module.exports = Image; 16 | -------------------------------------------------------------------------------- /docker/front/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | 4 | location /api/ { 5 | proxy_pass http://api:8000/; 6 | } 7 | 8 | location / { 9 | root /usr/src/app/; 10 | index index.html index.htm index.php; 11 | if (-f $request_filename) { 12 | break; 13 | } 14 | 15 | rewrite ^/(.*)$ /index.html?r=$1; 16 | try_files $uri $uri/ =404; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /common.yml: -------------------------------------------------------------------------------- 1 | db: 2 | image: mysql 3 | environment: 4 | MYSQL_ROOT_PASSWORD: ting 5 | MYSQL_DATABASE: ting 6 | 7 | api: 8 | build: . 9 | dockerfile: docker/api/Dockerfile 10 | 11 | realtime: 12 | build: . 13 | dockerfile: docker/realtime/Dockerfile 14 | ports: 15 | - "8080:8080" 16 | 17 | front: 18 | build: . 19 | dockerfile: docker/front/Dockerfile 20 | ports: 21 | - "80:80" 22 | -------------------------------------------------------------------------------- /API/API/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for API project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "API.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /API/chat/tests/channel/test_model.py: -------------------------------------------------------------------------------- 1 | from chat.tests.common import * 2 | 3 | class ChannelModelTests(ChatTests): 4 | def test_channel_create(self): 5 | """ 6 | A channel must be saved in the database. 7 | """ 8 | channels = Channel.objects.filter(pk=self.channel.id) 9 | self.assertTrue(channels.exists()) 10 | self.assertEqual(channels.count(), 1) 11 | self.assertEqual(channels[0].name, self.channel.name) 12 | 13 | -------------------------------------------------------------------------------- /production.yml: -------------------------------------------------------------------------------- 1 | db: 2 | extends: 3 | file: common.yml 4 | service: db 5 | 6 | api: 7 | extends: 8 | file: common.yml 9 | service: api 10 | links: 11 | - db 12 | 13 | realtime: 14 | extends: 15 | file: common.yml 16 | service: realtime 17 | links: 18 | - front 19 | 20 | front: 21 | extends: 22 | file: common.yml 23 | service: front 24 | links: 25 | - api 26 | -------------------------------------------------------------------------------- /docker/api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM django:python2 2 | 3 | RUN mkdir -p /usr/src/{app,config,runtime} 4 | WORKDIR /usr/src/app 5 | 6 | COPY API/requirements.txt /usr/src/app/ 7 | RUN pip install --no-cache-dir -r requirements.txt 8 | 9 | COPY docker/config/common.json /usr/src/config/ 10 | COPY API/ /usr/src/app/ 11 | 12 | COPY docker/api/create-default-channels.py /usr/src/runtime/ 13 | COPY docker/api/run.sh /usr/src/runtime/ 14 | 15 | EXPOSE 8000 16 | CMD ["/usr/src/runtime/run.sh"] 17 | -------------------------------------------------------------------------------- /docker/front/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | 4 | [program:gulp] 5 | command=/usr/src/app/node_modules/.bin/gulp watchify 6 | directory=/usr/src/app 7 | stdout_logfile=/dev/stdout 8 | stdout_logfile_maxbytes=0 9 | stderr_logfile=/dev/stderr 10 | stderr_logfile_maxbytes=0 11 | 12 | [program:nginx] 13 | command=/usr/sbin/nginx -g "daemon off;" 14 | stdout_logfile=/dev/stdout 15 | stdout_logfile_maxbytes=0 16 | stderr_logfile=/dev/stderr 17 | stderr_logfile_maxbytes=0 18 | -------------------------------------------------------------------------------- /docker/config/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "password": "apassword", 3 | "node": { 4 | "hostname": "front", 5 | "port": 8080 6 | }, 7 | "django": { 8 | "port": 8000, 9 | "secret_key": "the_big_secret", 10 | "allowed_hosts": [ 11 | ], 12 | "database": { 13 | "name": "ting", 14 | "user": "root", 15 | "password": "ting", 16 | "host": "db" 17 | }, 18 | "debug": 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /docker/realtime/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node 2 | 3 | RUN mkdir -p /usr/src/{app,config,runtime} 4 | WORKDIR /usr/src/app 5 | 6 | RUN JOBS=MAX npm install -g nodemon 7 | 8 | COPY docker/realtime/build.sh /usr/src/runtime/ 9 | COPY docker/realtime/run.sh /usr/src/runtime/ 10 | 11 | COPY realtime/package.json /usr/src/app/ 12 | RUN /usr/src/runtime/build.sh 13 | 14 | EXPOSE 8080 15 | 16 | COPY docker/config/common.json /usr/src/config/ 17 | COPY realtime/ /usr/src/app/ 18 | CMD ["/usr/src/runtime/run.sh"] 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: "node" 3 | sudo: true 4 | before_script: 5 | - cd realtime; npm install; cd .. 6 | - cd client; npm install; bower install; cd .. 7 | script: 8 | - cd realtime && npm test && cd .. 9 | - cd client && npm test && cd .. 10 | - cd client && find . -regex ".*\.jsx?"|grep -v node_modules/|grep -v bower_components/|grep -v dist/|xargs eslint && cd .. 11 | - cd API && sudo pip install --no-cache-dir -r requirements.txt && python manage.py test chat && cd .. 12 | -------------------------------------------------------------------------------- /API/chat/migrations/0004_auto_20150905_1700.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('chat', '0003_auto_20150902_1854'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='message', 16 | name='datetime_start', 17 | field=models.DateTimeField(default=None), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /API/chat/migrations/0002_auto_20150731_1920.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('chat', '0001_squashed_0002_auto_20150707_1647'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='channel', 16 | name='name', 17 | field=models.CharField(unique=True, max_length=20), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /config/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "password": "apassword", 3 | "node": { 4 | "hostname": "ting.gr", 5 | "port": 8080 6 | }, 7 | "django": { 8 | "port": 8000, 9 | "secret_key": "the_big_secret", 10 | "allowed_hosts": [ 11 | "ting.gr", 12 | "www.ting.gr" 13 | ], 14 | "database": { 15 | "name": "ting", 16 | "user": "ting", 17 | "password": "ting", 18 | "host": "" 19 | }, 20 | "debug": 0 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docker/api/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | LOOP_LIMIT=60 3 | for (( i=0 ; ; i++ )); do 4 | if [ ${i} -eq ${LOOP_LIMIT} ]; then 5 | echo "=> Could not connect to the db container. Shutting down." 6 | exit 1 7 | fi 8 | echo "=> Waiting for the db container to start up, trying ${i}/${LOOP_LIMIT}..." 9 | sleep 1 10 | mysql -hdb -uroot -pting -e "status" > /dev/null 2>&1 && break 11 | done 12 | 13 | python manage.py migrate 14 | python manage.py shell < /usr/src/runtime/create-default-channels.py 15 | python manage.py runserver 0.0.0.0:8000 16 | -------------------------------------------------------------------------------- /etc/config/systemd/ting_api.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Ting API Service 3 | Documentation=https://github.com/dionyziz/ting 4 | After=network.target 5 | 6 | [Service] 7 | WorkingDirectory=/var/www/ting.gr/html/API 8 | ExecStart=/var/www/ting.gr/html/API/venv/bin/python manage.py runserver 9 | Restart=always 10 | # Restart service after 10 seconds if node service crashes 11 | RestartSec=10 12 | KillSignal=SIGQUIT 13 | # Output to syslog 14 | StandardOutput=syslog 15 | StandardError=syslog 16 | SyslogIdentifier=ting_api 17 | 18 | [Install] 19 | WantedBy=multi-user.target 20 | -------------------------------------------------------------------------------- /API/chat/utils.py: -------------------------------------------------------------------------------- 1 | import pytz 2 | import datetime 3 | import time 4 | 5 | 6 | def datetime_to_timestamp(datetime): 7 | """ 8 | Takes a datetime and returns 9 | a unix epoch ms. 10 | """ 11 | time_without_ms = time.mktime(datetime.timetuple()) * 1000 12 | ms = int(datetime.microsecond / 1000) 13 | 14 | return time_without_ms + ms 15 | 16 | def timestamp_to_datetime(timestamp): 17 | """ 18 | Takes a timestamp and returns 19 | a datetime. 20 | """ 21 | return datetime.datetime.fromtimestamp(timestamp / 1000.0).replace(tzinfo=pytz.UTC) 22 | -------------------------------------------------------------------------------- /realtime/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "author": "The Ting Team", 7 | "license": "MIT", 8 | "scripts": { 9 | "test": "forever start server.js && jasmine-node tests/ --matchall" 10 | }, 11 | "dependencies": { 12 | "lodash": "^4.17.4", 13 | "request": "^2.81.0", 14 | "socket.io": "^2.0.3", 15 | "winston": "^2.3.1" 16 | }, 17 | "devDependencies": { 18 | "forever": "^0.15.3", 19 | "jasmine-node": "^1.14.5", 20 | "socket.io-client": "^2.0.3" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /etc/config/systemd/ting_realtime.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Ting Realtime Service 3 | Documentation=https://github.com/dionyziz/ting 4 | After=network.target 5 | 6 | [Service] 7 | WorkingDirectory=/var/www/ting.gr/html/realtime 8 | ExecStart=/usr/bin/node /var/www/ting.gr/html/realtime/server.js 9 | Restart=always 10 | # Restart service after 10 seconds if node service crashes 11 | RestartSec=10 12 | # Output to syslog 13 | StandardOutput=syslog 14 | StandardError=syslog 15 | SyslogIdentifier=ting_realtime 16 | Environment=NODE_ENV=production 17 | 18 | [Install] 19 | WantedBy=multi-user.target 20 | -------------------------------------------------------------------------------- /API/chat/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.views.decorators.csrf import csrf_exempt 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | url( 8 | r'^messages/(?P[0-9]+)/$', 9 | csrf_exempt(views.MessageView.as_view()), 10 | name='message' 11 | ), 12 | url( 13 | r'^messages/(?P[a-z]+)/(?P[a-zA-Z0-9_.-]+)/$', 14 | csrf_exempt(views.MessageView.as_view()), 15 | name='message' 16 | ), 17 | url( 18 | r'^channels/', 19 | views.ChannelView.as_view(), 20 | name='channel' 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /docker/front/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gtklocker/nginx-nodejs-base:v0.0.2 2 | 3 | RUN mkdir -p /usr/src/{app,runtime} 4 | WORKDIR /usr/src/app 5 | 6 | ENV PATH /usr/src/app/node_modules/.bin:$PATH 7 | COPY docker/front/build.sh /usr/src/runtime/ 8 | COPY docker/front/run.sh /usr/src/runtime/ 9 | 10 | COPY client/package.json /usr/src/app/ 11 | COPY client/bower.json /usr/src/app/ 12 | RUN /usr/src/runtime/build.sh 13 | 14 | COPY docker/front/nginx.conf /etc/nginx/conf.d/default.conf 15 | COPY docker/front/supervisord.conf /etc/supervisor/conf.d/supervisord.conf 16 | COPY client/ /usr/src/app/ 17 | CMD ["/usr/src/runtime/run.sh"] 18 | -------------------------------------------------------------------------------- /client/avatar.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | 3 | class Avatar extends React.Component { 4 | _getAvatar = (username) => { 5 | return 'https://avatars.githubusercontent.com/' + username.toLowerCase(); 6 | }; 7 | 8 | render() { 9 | if (this.props.username == null) { 10 | return null; 11 | } 12 | 13 | const src = this._getAvatar(this.props.username); 14 | 15 | return ( 16 | {this.props.username} 19 | ); 20 | } 21 | } 22 | 23 | module.exports = Avatar; 24 | -------------------------------------------------------------------------------- /API/chat/tests/message/common.py: -------------------------------------------------------------------------------- 1 | from chat.tests.common import * 2 | 3 | from chat.models import Message 4 | from chat.utils import datetime_to_timestamp, timestamp_to_datetime 5 | 6 | def create_message(message_content, timestamp, username, channel, message_type): 7 | """ 8 | Creates a message with the given text, datetime, 9 | username, channel and with typing set to True. 10 | """ 11 | return Message.objects.create( 12 | message_content=message_content, 13 | datetime_start=timestamp_to_datetime(timestamp), 14 | username=username, 15 | typing=True, 16 | channel=channel, 17 | message_type=message_type 18 | ) 19 | 20 | -------------------------------------------------------------------------------- /API/chat/tests/channel/test_post_view.py: -------------------------------------------------------------------------------- 1 | from chat.tests.common import * 2 | 3 | class ChannelViewPOSTTests(ChatTests): 4 | def test_create_valid_channel(self): 5 | """ 6 | When a channel is created the view should 7 | respond with a 204(No Content) code and save the channel 8 | in the database. 9 | """ 10 | response = self.client.post( 11 | reverse('chat:channel'), 12 | {'name': 'New_Channel'} 13 | ) 14 | 15 | self.assertTrue(Channel.objects.filter(name='New_Channel').exists()) 16 | self.assertEqual(Channel.objects.filter(name='New_Channel').count(), 1) 17 | self.assertEqual(response.status_code, 204) 18 | 19 | -------------------------------------------------------------------------------- /API/chat/migrations/0005_auto_20160511_1921.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('chat', '0004_auto_20150905_1700'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RenameField( 15 | model_name='message', 16 | old_name='text', 17 | new_name='message_content', 18 | ), 19 | migrations.AddField( 20 | model_name='message', 21 | name='message_type', 22 | field=models.CharField(default=b'text', max_length=10, choices=[(b'text', b'text'), (b'image', b'image')]), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /client/message/content.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'), 2 | Text = require('./text.jsx'), 3 | Image = require('./image.jsx'); 4 | 5 | class MessageContent extends React.Component { 6 | render() { 7 | var message_content = this.props.message_content; 8 | var messageType = this.props.messageType; 9 | var message_class = null; 10 | 11 | switch (messageType) { 12 | case 'text': 13 | message_class = ; 14 | break; 15 | 16 | case 'image': 17 | message_class = ; 18 | break; 19 | } 20 | 21 | return message_class; 22 | } 23 | } 24 | 25 | module.exports = MessageContent; 26 | -------------------------------------------------------------------------------- /development.yml: -------------------------------------------------------------------------------- 1 | db: 2 | extends: 3 | file: common.yml 4 | service: db 5 | 6 | api: 7 | extends: 8 | file: common.yml 9 | service: api 10 | command: sh -c "/usr/src/runtime/run.sh" 11 | volumes: 12 | - ./API:/usr/src/app 13 | links: 14 | - db 15 | 16 | realtime: 17 | extends: 18 | file: common.yml 19 | service: realtime 20 | command: sh -c "/usr/src/runtime/build.sh && /usr/src/runtime/run.sh" 21 | volumes: 22 | - ./realtime:/usr/src/app 23 | links: 24 | - front 25 | 26 | front: 27 | extends: 28 | file: common.yml 29 | service: front 30 | command: sh -c "/usr/src/runtime/build.sh && /usr/src/runtime/run.sh" 31 | volumes: 32 | - ./client:/usr/src/app 33 | links: 34 | - api 35 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": [ 4 | 1, 5 | 4 6 | ], 7 | "no-unused-vars": [ 8 | 1 9 | ], 10 | "quotes": [ 11 | 2, 12 | "single" 13 | ], 14 | "linebreak-style": [ 15 | 2, 16 | "unix" 17 | ], 18 | "semi": [ 19 | 2, 20 | "always" 21 | ] 22 | }, 23 | "env": { 24 | "browser": true, 25 | "commonjs": true, 26 | "es6": true, 27 | "jquery": true, 28 | "jest": true 29 | }, 30 | "parserOptions": { 31 | "ecmaFeatures": { 32 | "jsx": true 33 | } 34 | }, 35 | "extends": "eslint:recommended", 36 | "plugins": [ 37 | "react", 38 | "require-path-exists" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /API/chat/migrations/0003_auto_20150902_1854.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('chat', '0002_auto_20150731_1920'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RenameField( 15 | model_name='message', 16 | old_name='datetime', 17 | new_name='datetime_start' 18 | ), 19 | migrations.AddField( 20 | model_name='message', 21 | name='datetime_sent', 22 | field=models.DateTimeField(default=None, null=True), 23 | ), 24 | migrations.AddField( 25 | model_name='message', 26 | name='typing', 27 | field=models.BooleanField(default=False), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /API/API/urls.py: -------------------------------------------------------------------------------- 1 | """API URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.8/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Add an import: from blog import urls as blog_urls 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls)) 15 | """ 16 | from django.conf.urls import include, url 17 | from django.contrib import admin 18 | 19 | urlpatterns = [ 20 | url(r'', include('chat.urls', namespace='chat')), 21 | url(r'^admin/', include(admin.site.urls)), 22 | ] 23 | -------------------------------------------------------------------------------- /client/message/text.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'), 2 | escape = require('escape-html'), 3 | emoticons = require('emoticons'), 4 | autolinks = require('autolinks'); 5 | 6 | class Text extends React.Component { 7 | _formatMessage = (message) => { 8 | var html = escape(message); 9 | 10 | html = emoticons.replace(html); 11 | 12 | return { 13 | __html: autolinks(html, (title, url) => { 14 | return ` 17 | ${title} 18 | `; 19 | }) 20 | }; 21 | }; 22 | 23 | render() { 24 | var message_content = this.props.message_content; 25 | 26 | return ( 27 |
28 |
29 | ); 30 | } 31 | } 32 | 33 | module.exports = Text; 34 | -------------------------------------------------------------------------------- /client/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "usernameSet": { 3 | "placeholder": "Choose a username", 4 | "submit": "Join", 5 | "errors": { 6 | "empty": "Choose a username.", 7 | "length": "Your username must be less than 20 letters.", 8 | "chars": "Your username must include only letters or numbers.", 9 | "taken": "This username is taken." 10 | } 11 | }, 12 | "messageInput": { 13 | "placeholder": "Type a message..." 14 | }, 15 | "topbarSet": { 16 | "settings": "Settings", 17 | "logout": "Log out" 18 | }, 19 | "settingsSet": { 20 | "changePic": "Change my picture", 21 | "password": "Password", 22 | "email": "E-mail", 23 | "birthDate": "Birth Date", 24 | "sex": "Sex", 25 | "region": "Region", 26 | "save": "Save" 27 | }, 28 | "gender": { 29 | "boy": "Boy", 30 | "girl": "Girl", 31 | "undefined": "-" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client/locales/el.json: -------------------------------------------------------------------------------- 1 | { 2 | "usernameSet": { 3 | "placeholder": "Γράψε ένα ψευδώνυμο", 4 | "submit": "Μπες", 5 | "errors": { 6 | "empty": "Γράψε ένα ψευδώνυμο.", 7 | "length": "Το ψευδώνυμο πρέπει να είναι έως 20 γράμματα.", 8 | "chars": "Το ψευδώνυμο πρέπει να περιλαμβάνει μόνο γράμματα ή αριθμούς.", 9 | "taken": "Το ψευδώνυμο το έχει άλλος." 10 | } 11 | }, 12 | "messageInput": { 13 | "placeholder": "Γράψε ένα μήνυμα..." 14 | }, 15 | "topbarSet": { 16 | "settings": "Ρυθμίσεις", 17 | "logout": "Έξοδος" 18 | }, 19 | "settingsSet": { 20 | "changePic": "Αλλαγή της εικόνας μου", 21 | "password": "Κωδικός", 22 | "email": "E-mail", 23 | "birthDate": "Ημερομηνία Γέννησης", 24 | "sex": "Φύλο", 25 | "region": "Περιοχή", 26 | "save": "Αποθήκευση" 27 | }, 28 | "gender": { 29 | "boy": "Aγόρι", 30 | "girl": "Κορίτσι", 31 | "undefined": "-" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client/message/view.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'), 2 | Avatar = require('../avatar.jsx'), 3 | MessageContent = require('./content.jsx'); 4 | 5 | class Message extends React.Component { 6 | render() { 7 | var className, message_content = this.props.message_content; 8 | 9 | if (this.props.own) { 10 | className = 'self'; 11 | } 12 | else { 13 | className = 'other'; 14 | } 15 | 16 | if (this.props.typing) { 17 | className += ' typing'; 18 | } 19 | 20 | if (this.props.own && this.props.typing) { 21 | message_content = '...'; 22 | } 23 | 24 | return ( 25 |
  • 26 | 27 | {this.props.username} 28 | 30 |
  • 31 | ); 32 | } 33 | } 34 | 35 | module.exports = Message; 36 | -------------------------------------------------------------------------------- /API/chat/migrations/0001_squashed_0002_auto_20150707_1647.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Channel', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('name', models.CharField(max_length=20)), 18 | ], 19 | ), 20 | migrations.CreateModel( 21 | name='Message', 22 | fields=[ 23 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 24 | ('text', models.TextField(max_length=2000)), 25 | ('datetime', models.DateTimeField()), 26 | ('channel', models.ForeignKey(to='chat.Channel')), 27 | ('username', models.CharField(max_length=20)), 28 | ], 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /client/css/settings.css: -------------------------------------------------------------------------------- 1 | #settings { 2 | width: 400px; 3 | margin: 40px auto 0 auto; 4 | } 5 | #settings #icon-username { 6 | display: flex; 7 | flex-direction: row; 8 | justify-content: center; 9 | } 10 | #icon-username h2 { 11 | margin-left: 12px; 12 | } 13 | #settings .icon { 14 | width: 100px; 15 | height: 150px; 16 | border: 1px solid black; 17 | overflow: hidden; 18 | } 19 | .icon img { 20 | height: 100px; 21 | width: 100px; 22 | } 23 | .icon span { 24 | padding: 4px 10px; 25 | border-top: 1px solid black; 26 | cursor: pointer; 27 | display: block; 28 | } 29 | #settings #form { 30 | margin-top: 50px; 31 | } 32 | #form .button { 33 | text-decoration: none; 34 | } 35 | .button button { 36 | color: white; 37 | background-color: #0084ff; 38 | border: 1px solid #0084ff; 39 | padding: 10px 0; 40 | width: 120px; 41 | display: block; 42 | margin: 40px auto 0 auto; 43 | } 44 | .no-gutters { 45 | margin-left: 0; 46 | margin-right: 0; 47 | } 48 | .no-gutters [class*='col-']:not(:first-child) { 49 | padding-right: 5px; 50 | padding-left: 0; 51 | } 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Ting team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/select.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'), 2 | _ = require('lodash'); 3 | 4 | class Select extends React.Component { 5 | render() { 6 | var elements = null, 7 | options = null; 8 | 9 | if (this.props.start != null) { 10 | if (this.props.start < this.props.end) { 11 | elements = _.range(this.props.start, this.props.end + 1); 12 | } 13 | else { 14 | elements = _.rangeRight(this.props.end, this.props.start + 1); 15 | } 16 | } 17 | else { 18 | elements = this.props.container; 19 | } 20 | 21 | if (elements != null) { 22 | options = elements.map(function(elem, ind) { 23 | return ; 24 | }); 25 | } 26 | 27 | var classProp = 'form-control ' + this.props.classProp, 28 | idProp = this.props.idProp; 29 | 30 | return ( 31 | 34 | ); 35 | } 36 | } 37 | 38 | module.exports = Select; 39 | -------------------------------------------------------------------------------- /API/chat/tests/channel/test_get_view.py: -------------------------------------------------------------------------------- 1 | from chat.tests.common import * 2 | 3 | class ChannelViewGETTests(ChatTests): 4 | def test_request_valid_channel(self): 5 | """ 6 | When a channel with a name that exists in 7 | the database is requested, the view should return 8 | a JSON object containing the name of the channel 9 | and a 200(OK) status code. 10 | """ 11 | response = self.client.get( 12 | reverse('chat:channel'), 13 | {'name': self.channel.name} 14 | ) 15 | returned_channel = json.loads(response.content) 16 | 17 | self.assertEqual(response.status_code, 200) 18 | self.assertEqual(returned_channel['name'], self.channel.name) 19 | 20 | def test_request_channel_that_does_not_exist(self): 21 | """ 22 | When a channel that does not exist is requested 23 | the view should return a 404(Not Found) status code. 24 | """ 25 | response = self.client.get( 26 | reverse('chat:channel'), 27 | {'name': 'invalid_channel'} 28 | ) 29 | 30 | self.assertEqual(response.status_code, 404) 31 | 32 | -------------------------------------------------------------------------------- /API/chat/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Channel(models.Model): 5 | def __str__(self): 6 | return self.name 7 | 8 | name = models.CharField(max_length=20, unique=True) 9 | 10 | 11 | class Message(models.Model): 12 | def __str__(self): 13 | return self.message_content 14 | 15 | def to_dict(self): 16 | serializable_fields = ('message_content', 'datetime_start', 'datetime_sent', 'username') 17 | return {key: getattr(self, key) for key in serializable_fields} 18 | 19 | TEXT = 'text' 20 | IMAGE = 'image' 21 | 22 | MESSAGE_TYPE = ( 23 | (TEXT, 'text'), 24 | (IMAGE, 'image'), 25 | ) 26 | 27 | message_content = models.TextField(max_length=2000) 28 | datetime_start = models.DateTimeField(default=None) 29 | datetime_sent = models.DateTimeField(default=None, null=True) 30 | typing = models.BooleanField(default=False) 31 | username = models.CharField(max_length=20) 32 | channel = models.ForeignKey(Channel) 33 | message_type = models.CharField(max_length=10, 34 | choices=MESSAGE_TYPE, 35 | default=TEXT) 36 | -------------------------------------------------------------------------------- /client/topbar.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'), 2 | Avatar = require('./avatar.jsx'), 3 | Link = require('react-router-dom').Link, 4 | i18n = require('i18next'); 5 | 6 | class TopBar extends React.Component { 7 | state = { 8 | username: null 9 | }; 10 | 11 | onLogin = (username) => { 12 | this.setState({username}); 13 | }; 14 | 15 | onReload = (e) => { 16 | e.preventDefault(); 17 | window.location.reload(); 18 | }; 19 | 20 | render() { 21 | return ( 22 |
    23 | 26 |
      27 |
    • {i18n.t('topbarSet.settings')}
    • 28 |
    • {i18n.t('topbarSet.logout')}
    • 29 |
    30 |
    31 | ); 32 | } 33 | } 34 | 35 | module.exports = TopBar; 36 | -------------------------------------------------------------------------------- /client/analytics.js: -------------------------------------------------------------------------------- 1 | /* global ga: false */ 2 | 3 | var Analytics = { 4 | firstMessage: true, 5 | init() { 6 | /* eslint-disable */ 7 | (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ 8 | (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), 9 | m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) 10 | })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); 11 | /* eslint-enable */ 12 | 13 | ga('create', 'UA-64452066-1', 'auto'); 14 | ga('send', 'pageview'); 15 | }, 16 | onMessageSubmit(message) { 17 | if (this.firstMessage) { 18 | ga('send', 'event', { 19 | eventCategory: 'chat', 20 | eventAction: 'chat_form_submit', 21 | eventLabel: 'send', 22 | eventValue: 1 23 | }); 24 | this.firstMessage = false; 25 | } 26 | }, 27 | onLoginIntention(username) { 28 | ga('send', 'event', { 29 | eventCategory: 'join', 30 | eventAction: 'username_set', 31 | eventLabel: 'submit', 32 | eventValue: 1 33 | }); 34 | } 35 | }; 36 | 37 | module.exports = Analytics; 38 | -------------------------------------------------------------------------------- /client/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const webpack = require('webpack'), 3 | path = require('path'); 4 | 5 | module.exports = { 6 | entry: './main.js', 7 | output: { 8 | filename: 'main.js', 9 | path: path.resolve(__dirname, 'dist') 10 | }, 11 | devtool: 'source-map', 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.jsx?$/, 16 | exclude: /node_modules/, 17 | use: { 18 | loader: 'babel-loader' 19 | } 20 | }, 21 | { 22 | test: /\.css$/, 23 | use: [ 24 | 'style-loader', 25 | 'css-loader' 26 | ] 27 | }, 28 | { 29 | test: /\.(woff|woff2|eot|ttf|otf)$/, 30 | use: [ 31 | 'file-loader' 32 | ] 33 | }, 34 | { 35 | test: /\.(png|svg|jpg|gif)$/, 36 | use: [ 37 | 'file-loader' 38 | ] 39 | } 40 | ] 41 | }, 42 | plugins: [ 43 | // bootstrap@3 needs this 44 | new webpack.ProvidePlugin({ 45 | jQuery: 'jquery' 46 | }) 47 | ] 48 | }; 49 | -------------------------------------------------------------------------------- /API/chat/tests/common.py: -------------------------------------------------------------------------------- 1 | import time 2 | import json 3 | import datetime 4 | import urllib 5 | 6 | from django.test import TestCase, Client 7 | from django_dynamic_fixture import G 8 | from django.core.urlresolvers import reverse 9 | from django.utils.dateformat import format 10 | from chat.utils import datetime_to_timestamp, timestamp_to_datetime 11 | from django.conf import settings 12 | 13 | from chat.models import Message, Channel 14 | 15 | class ChatClient(Client): 16 | def delete(self, url, qstring, *args, **kwargs): 17 | return Client().delete( 18 | url, 19 | qstring, 20 | content_type='application/x-www-form-urlencoded', 21 | *args, 22 | **kwargs 23 | ) 24 | 25 | def patch(self, url, qstring, *args, **kwargs): 26 | return Client().patch( 27 | url, 28 | qstring, 29 | content_type='application/x-www-form-urlencoded', 30 | *args, 31 | **kwargs 32 | ) 33 | 34 | 35 | class ChatTests(TestCase): 36 | def setUp(self): 37 | super(ChatTests, self).setUp() 38 | self.channel = G(Channel, name='Channel') 39 | 40 | def privileged_operation(self, endpoint, data, method): 41 | return getattr(self.client, method)( 42 | endpoint, 43 | data, 44 | HTTP_AUTHORIZATION=settings.PASS 45 | ) 46 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ting", 3 | "version": "0.1.0", 4 | "description": "A chat platform", 5 | "main": "client.js", 6 | "scripts": { 7 | "test": "jest", 8 | "build": "webpack", 9 | "watch": "webpack --watch" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/dionyziz/ting.git" 14 | }, 15 | "author": "the Ting team", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/dionyziz/ting/issues" 19 | }, 20 | "homepage": "https://github.com/dionyziz/ting", 21 | "dependencies": { 22 | "autolinks": "^0.1.0", 23 | "bootstrap": "3", 24 | "bower": "^1.5.2", 25 | "classnames": "^2.1.3", 26 | "emoticons": "^0.1.8", 27 | "escape-html": "^1.0.2", 28 | "i18next": "^9.0.0", 29 | "immutability-helper": "^2.3.0", 30 | "jquery": "^3.2.1", 31 | "lodash": "^4.17.4", 32 | "react": "^15.3.1", 33 | "react-dom": "^15.3.1", 34 | "react-router": "^4.1.1", 35 | "react-router-dom": "^4.1.1", 36 | "socket.io-client": "^2.0.3" 37 | }, 38 | "devDependencies": { 39 | "babel-core": "^6.26.0", 40 | "babel-eslint": "^8.0.0", 41 | "babel-jest": "^21.0.2", 42 | "babel-loader": "^7.1.2", 43 | "babel-plugin-transform-class-properties": "^6.24.1", 44 | "babel-preset-env": "^1.6.0", 45 | "babel-preset-react": "^6.24.1", 46 | "css-loader": "^0.28.7", 47 | "eslint": "^4.7.0", 48 | "eslint-plugin-react": "^7.3.0", 49 | "eslint-plugin-require-path-exists": "^1.0.15", 50 | "file-loader": "^0.11.2", 51 | "jest": "^21.1.0", 52 | "jest-cli": "^21.1.0", 53 | "style-loader": "^0.18.2", 54 | "webpack": "^3.5.6" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /client/userlist.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'), 2 | Avatar = require('./avatar.jsx'), 3 | Update = require('immutability-helper'); 4 | 5 | class UserList extends React.Component { 6 | state = { 7 | users: [], 8 | myUsername: null 9 | }; 10 | 11 | onLogin = (myUsername, users) => { 12 | this.setState({myUsername, users}); 13 | }; 14 | 15 | onJoin = (username) => { 16 | if (username != this.state.myUsername) { 17 | var newState = Update( 18 | this.state, { 19 | users: { 20 | $push: [username] 21 | } 22 | } 23 | ); 24 | this.setState(newState); 25 | } 26 | }; 27 | 28 | onPart = (username) => { 29 | var newUsers = this.state.users.filter((name) => { 30 | return username != name; 31 | }); 32 | this.setState({ 33 | users: newUsers 34 | }); 35 | }; 36 | 37 | render() { 38 | var userNodes = this.state.users.map((user) => { 39 | return ( 40 | 41 | ); 42 | }); 43 | 44 | return ( 45 |
      46 |
    • ting
    • 47 | {userNodes} 48 |
    49 | ); 50 | } 51 | } 52 | 53 | class User extends React.Component { 54 | render() { 55 | return ( 56 |
  • 57 | 58 | {this.props.username} 59 |
  • 60 | ); 61 | } 62 | } 63 | 64 | module.exports = UserList; 65 | -------------------------------------------------------------------------------- /API/chat/tests/test_privileged.py: -------------------------------------------------------------------------------- 1 | from chat.tests.common import * 2 | from chat.views import privileged 3 | from django.http import HttpResponse, HttpRequest 4 | 5 | class DecoratorTests(TestCase): 6 | @privileged 7 | def privileged_view_mock(self, request, *args, **kwargs): 8 | """ 9 | Mock of a view that returns an HttpResponse 10 | with status code 200(OK). 11 | """ 12 | return HttpResponse(status=200) 13 | 14 | def get_request(self, password=None): 15 | request = HttpRequest() 16 | request.META = {} 17 | request.META['HTTP_AUTHORIZATION'] = password 18 | return request 19 | 20 | def test_request_with_correct_password(self): 21 | """ 22 | When the password is correct the decorator should call 23 | the function passed. 24 | """ 25 | request = self.get_request(password=settings.PASS) 26 | 27 | response = self.privileged_view_mock(request) 28 | self.assertEqual(response.status_code, 200) 29 | 30 | def test_request_with_wrong_password(self): 31 | """ 32 | When the password is wrong the decorator should respond 33 | with a 401(Unauthorized) status code. 34 | """ 35 | request = self.get_request(password='wrong') 36 | 37 | response = self.privileged_view_mock(request) 38 | self.assertEqual(response.status_code, 401) 39 | 40 | def test_request_with_HTTP_AUTHORIZATION_not_defined(self): 41 | """ 42 | When the password is not defined the decorator should 43 | respond with a 401(Unauthorized) status code. 44 | """ 45 | request = self.get_request() 46 | 47 | response = self.privileged_view_mock(request) 48 | self.assertEqual(response.status_code, 401) 49 | -------------------------------------------------------------------------------- /API/chat/forms.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from django import forms 4 | from .models import Message 5 | from .utils import timestamp_to_datetime, datetime_to_timestamp 6 | 7 | 8 | 9 | class MessageForm(forms.Form): 10 | message_content = forms.CharField(widget=forms.Textarea) 11 | typing = forms.BooleanField(required=False) 12 | message_type = forms.CharField(widget=forms.Textarea) 13 | 14 | 15 | class MessageCreationForm(MessageForm): 16 | username = forms.CharField(max_length=20) 17 | datetime_start = forms.IntegerField() 18 | 19 | def clean_datetime_start(self): 20 | now = int(round(time.time() * 1000)) 21 | timestamp = int(self.data['datetime_start']) 22 | if now < timestamp: 23 | timestamp = now 24 | 25 | self.cleaned_data['datetime_start'] = timestamp_to_datetime(timestamp) 26 | 27 | def save(self): 28 | self.clean_datetime_start() 29 | 30 | message = Message.objects.create(channel=self.channel, **self.cleaned_data) 31 | 32 | if not message.typing: 33 | message.datetime_sent = message.datetime_start 34 | message.save() 35 | 36 | return message; 37 | 38 | 39 | class MessagePatchForm(MessageForm): 40 | datetime_sent = forms.IntegerField() 41 | 42 | def save(self, message): 43 | timestamp_start = datetime_to_timestamp(message.datetime_start) 44 | timestamp_sent = int(self.cleaned_data['datetime_sent']) 45 | 46 | if timestamp_sent < timestamp_start: 47 | timestamp_sent = timestamp_start 48 | 49 | message.datetime_sent = timestamp_to_datetime(timestamp_sent) 50 | message.message_content = self.cleaned_data['message_content'] 51 | message.typing = self.cleaned_data.get('typing', False) 52 | 53 | message.save() 54 | -------------------------------------------------------------------------------- /etc/mockups/topbar-mockup/topbar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Ting Mockup 6 | 7 | 8 | 9 | 10 | 11 |
    12 |

    Ting

    13 | img 14 |
    15 | Ρυθμίσεις 16 | Έξοδος 17 |
    18 |
    19 | 20 |
    21 |
    22 |
      23 |
    • ting
    • 24 |
    25 |
    26 | 27 |
    28 |
    29 |
    30 |
    31 | 32 |
    33 |
    34 |
    35 | 36 |
    37 |
    38 |
    39 |
    40 | 41 | 42 | 43 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /API/chat/tests/message/test_delete_view.py: -------------------------------------------------------------------------------- 1 | from chat.tests.message.common import * 2 | 3 | class MessageViewDELETETests(ChatTests): 4 | client_class = ChatClient 5 | def test_delete_message(self): 6 | """ 7 | The view should delete the message with the 8 | specified id and respond with a 204(No Content) 9 | code. 10 | """ 11 | timestamp = 10 ** 11 12 | message = create_message( 13 | message_content='Message', 14 | username='vitsalis', 15 | channel=self.channel, 16 | timestamp=timestamp, 17 | message_type='text' 18 | ) 19 | 20 | response = self.privileged_operation( 21 | reverse('chat:message', args=(message.id,)), 22 | {}, 23 | 'delete' 24 | ) 25 | 26 | messages = Message.objects.filter(username='vitsalis') 27 | 28 | self.assertEqual(response.status_code, 204) 29 | self.assertEqual(len(messages), 0) 30 | 31 | def test_delete_message_that_does_not_exist(self): 32 | """ 33 | When a message with the specified id doesn't exist 34 | the view should respond with a 404(Not Found) code. 35 | """ 36 | timestamp = 10 ** 11 37 | message = create_message( 38 | message_content='Message', 39 | username='vitsalis', 40 | channel=self.channel, 41 | timestamp=timestamp, 42 | message_type='text' 43 | ) 44 | 45 | response = self.privileged_operation( 46 | reverse('chat:message', args=(message.id + 1,)), 47 | {}, 48 | 'delete' 49 | ) 50 | 51 | self.assertEqual(response.status_code, 404) 52 | 53 | messages = Message.objects.filter(username='vitsalis') 54 | 55 | self.assertEqual(len(messages), 1) 56 | 57 | -------------------------------------------------------------------------------- /API/chat/tests/message/test_model.py: -------------------------------------------------------------------------------- 1 | from chat.tests.message.common import * 2 | 3 | class MessageModelTests(ChatTests): 4 | def test_message_create(self): 5 | """ 6 | A message must be saved correctly in the database. 7 | """ 8 | message = create_message( 9 | message_content='Message', 10 | timestamp=10 ** 11, 11 | username='vitsalis', 12 | channel=self.channel, 13 | message_type='text' 14 | ) 15 | 16 | messages = Message.objects.filter(pk=message.id) 17 | 18 | self.assertTrue(messages.exists()) 19 | self.assertEqual(messages.count(), 1) 20 | 21 | dbmessage = messages[0] 22 | 23 | self.assertEqual(dbmessage.message_content, message.message_content) 24 | self.assertEqual(dbmessage.datetime_start, message.datetime_start) 25 | self.assertEqual(dbmessage.username, message.username) 26 | self.assertEqual(dbmessage.channel.id, message.channel.id) 27 | self.assertTrue(dbmessage.typing) 28 | 29 | link = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHgAAAB4CAMAAAAOusbgAAAAG1BMVEX///8AAACdnZ3X19eZmZnq6ur7+/vOzs7x8fHjK9NyAAAARElEQVRoge3NiQmAMBAAsNrr4/4TC9YNlBNKskBKAT42W7317LgdS2THVSzeLu6xnCN7foy/YgAAAAAAAAAAAAAAgJcuaoEAp2NAe+UAAAAASUVORK5CYII=' 30 | 31 | message = create_message( 32 | message_content=link, 33 | timestamp=10 ** 11, 34 | username='vitsalis', 35 | channel=self.channel, 36 | message_type='image' 37 | ) 38 | 39 | messages = Message.objects.filter(pk=message.id) 40 | 41 | self.assertTrue(messages.exists()) 42 | self.assertEqual(messages.count(), 1) 43 | 44 | dbmessage = messages[0] 45 | 46 | self.assertEqual(dbmessage.message_content, message.message_content) 47 | self.assertEqual(dbmessage.datetime_start, message.datetime_start) 48 | self.assertEqual(dbmessage.username, message.username) 49 | self.assertEqual(dbmessage.channel.id, message.channel.id) 50 | self.assertTrue(dbmessage.typing) 51 | 52 | -------------------------------------------------------------------------------- /etc/mockups/login-mockups/css/customstyle.css: -------------------------------------------------------------------------------- 1 | body{ 2 | background: #FFF; 3 | } 4 | 5 | .container .jumbotron{ 6 | border-radius: 0; 7 | color: #046380; 8 | margin: 0; 9 | } 10 | 11 | .account-details{ 12 | background: #444; 13 | box-shadow: 0px 0px 5px #444; 14 | } 15 | 16 | .account-details h4{ 17 | margin: 0; 18 | padding: 10px; 19 | color: #CCC; 20 | } 21 | 22 | .account-details h4 span{ 23 | color: #A7A37E; 24 | } 25 | 26 | .content_area{ 27 | background: #F2F2F2; 28 | padding-top: 10px; 29 | } 30 | 31 | .single_widget{ 32 | background: #CCC; 33 | margin: 10px 0px; 34 | border-radius: 6px; 35 | box-shadow: 2px 2px 0px #888; 36 | } 37 | 38 | .single_widget h3{ 39 | color: #046380 40 | } 41 | 42 | .single_widget h4{ 43 | color: #A7A37E; 44 | text-align: center; 45 | padding-top: 5px; 46 | } 47 | 48 | .single_widget .table h4{ 49 | padding: 0; 50 | margin: 0; 51 | } 52 | 53 | .single_widget .inner_area{ 54 | margin-top: 20px; 55 | background: #F2F2F2; 56 | border-radius: 6px; 57 | } 58 | 59 | .single_widget .table tr td:nth-child(1){ 60 | text-align: right; 61 | width: 50%; 62 | } 63 | 64 | .single_widget .table tr td:nth-child(1) h4{ 65 | text-align: right; 66 | } 67 | 68 | .single_widget .table tr td:nth-child(2) h4{ 69 | text-align: left; 70 | } 71 | 72 | .inner_area.data_sheet{ 73 | border-radius: 0; 74 | background: #EFECCA 75 | } 76 | 77 | .single_widget .data_sheet .table tr td{ 78 | text-align: left; 79 | width: auto; 80 | } 81 | 82 | .single_widget .data_sheet .table tr th{ 83 | color: #046380 84 | } 85 | 86 | .single_widget .table.table-striped{ 87 | margin-bottom: 20px; 88 | } 89 | 90 | .single_widget .table-striped tr td{ 91 | padding: 15px; 92 | } 93 | 94 | .single_widget .table_title{ 95 | color: #046380; 96 | text-align: left; 97 | } 98 | 99 | .text_field{ 100 | background: #F2F2F2; 101 | border-radius: 0; 102 | box-shadow: inset 0px 0px 5px #CCC; 103 | } 104 | 105 | .table.table-striped.form_table{ 106 | background: #FFF 107 | } 108 | 109 | .icons span{ 110 | padding: 5px; 111 | color: #888 112 | } -------------------------------------------------------------------------------- /etc/mockups/settings-mockup/css/customstyle.css: -------------------------------------------------------------------------------- 1 | body{ 2 | background: #FFF; 3 | } 4 | 5 | .container .jumbotron{ 6 | border-radius: 0; 7 | color: #046380; 8 | margin: 0; 9 | } 10 | 11 | .account-details{ 12 | background: #444; 13 | box-shadow: 0px 0px 5px #444; 14 | } 15 | 16 | .account-details h4{ 17 | margin: 0; 18 | padding: 10px; 19 | color: #CCC; 20 | } 21 | 22 | .account-details h4 span{ 23 | color: #A7A37E; 24 | } 25 | 26 | .content_area{ 27 | background: #F2F2F2; 28 | padding-top: 10px; 29 | } 30 | 31 | .single_widget{ 32 | background: #CCC; 33 | margin: 10px 0px; 34 | border-radius: 6px; 35 | box-shadow: 2px 2px 0px #888; 36 | } 37 | 38 | .single_widget h3{ 39 | color: #046380 40 | } 41 | 42 | .single_widget h4{ 43 | color: #A7A37E; 44 | text-align: center; 45 | padding-top: 5px; 46 | } 47 | 48 | .single_widget .table h4{ 49 | padding: 0; 50 | margin: 0; 51 | } 52 | 53 | .single_widget .inner_area{ 54 | margin-top: 20px; 55 | background: #F2F2F2; 56 | border-radius: 6px; 57 | } 58 | 59 | .single_widget .table tr td:nth-child(1){ 60 | text-align: right; 61 | width: 50%; 62 | } 63 | 64 | .single_widget .table tr td:nth-child(1) h4{ 65 | text-align: right; 66 | } 67 | 68 | .single_widget .table tr td:nth-child(2) h4{ 69 | text-align: left; 70 | } 71 | 72 | .inner_area.data_sheet{ 73 | border-radius: 0; 74 | background: #EFECCA 75 | } 76 | 77 | .single_widget .data_sheet .table tr td{ 78 | text-align: left; 79 | width: auto; 80 | } 81 | 82 | .single_widget .data_sheet .table tr th{ 83 | color: #046380 84 | } 85 | 86 | .single_widget .table.table-striped{ 87 | margin-bottom: 20px; 88 | } 89 | 90 | .single_widget .table-striped tr td{ 91 | padding: 15px; 92 | } 93 | 94 | .single_widget .table_title{ 95 | color: #046380; 96 | text-align: left; 97 | } 98 | 99 | .text_field{ 100 | background: #F2F2F2; 101 | border-radius: 0; 102 | box-shadow: inset 0px 0px 5px #CCC; 103 | } 104 | 105 | .table.table-striped.form_table{ 106 | background: #FFF 107 | } 108 | 109 | .icons span{ 110 | padding: 5px; 111 | color: #888 112 | } -------------------------------------------------------------------------------- /etc/mockups/topbar-mockup/css/customstyle.css: -------------------------------------------------------------------------------- 1 | body{ 2 | background: #FFF; 3 | } 4 | 5 | .container .jumbotron{ 6 | border-radius: 0; 7 | color: #046380; 8 | margin: 0; 9 | } 10 | 11 | .account-details{ 12 | background: #444; 13 | box-shadow: 0px 0px 5px #444; 14 | } 15 | 16 | .account-details h4{ 17 | margin: 0; 18 | padding: 10px; 19 | color: #CCC; 20 | } 21 | 22 | .account-details h4 span{ 23 | color: #A7A37E; 24 | } 25 | 26 | .content_area{ 27 | background: #F2F2F2; 28 | padding-top: 10px; 29 | } 30 | 31 | .single_widget{ 32 | background: #CCC; 33 | margin: 10px 0px; 34 | border-radius: 6px; 35 | box-shadow: 2px 2px 0px #888; 36 | } 37 | 38 | .single_widget h3{ 39 | color: #046380 40 | } 41 | 42 | .single_widget h4{ 43 | color: #A7A37E; 44 | text-align: center; 45 | padding-top: 5px; 46 | } 47 | 48 | .single_widget .table h4{ 49 | padding: 0; 50 | margin: 0; 51 | } 52 | 53 | .single_widget .inner_area{ 54 | margin-top: 20px; 55 | background: #F2F2F2; 56 | border-radius: 6px; 57 | } 58 | 59 | .single_widget .table tr td:nth-child(1){ 60 | text-align: right; 61 | width: 50%; 62 | } 63 | 64 | .single_widget .table tr td:nth-child(1) h4{ 65 | text-align: right; 66 | } 67 | 68 | .single_widget .table tr td:nth-child(2) h4{ 69 | text-align: left; 70 | } 71 | 72 | .inner_area.data_sheet{ 73 | border-radius: 0; 74 | background: #EFECCA 75 | } 76 | 77 | .single_widget .data_sheet .table tr td{ 78 | text-align: left; 79 | width: auto; 80 | } 81 | 82 | .single_widget .data_sheet .table tr th{ 83 | color: #046380 84 | } 85 | 86 | .single_widget .table.table-striped{ 87 | margin-bottom: 20px; 88 | } 89 | 90 | .single_widget .table-striped tr td{ 91 | padding: 15px; 92 | } 93 | 94 | .single_widget .table_title{ 95 | color: #046380; 96 | text-align: left; 97 | } 98 | 99 | .text_field{ 100 | background: #F2F2F2; 101 | border-radius: 0; 102 | box-shadow: inset 0px 0px 5px #CCC; 103 | } 104 | 105 | .table.table-striped.form_table{ 106 | background: #FFF 107 | } 108 | 109 | .icons span{ 110 | padding: 5px; 111 | color: #888 112 | } -------------------------------------------------------------------------------- /etc/mockups/login-mockups/simple_login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Ting Mockup 6 | 7 | 8 | 9 | 10 | 11 |
    12 |

    Ting

    13 |
    14 | 15 |
    16 |
    17 |
      18 |
    • ting
    • 19 |
    20 |
    21 | 22 |
    23 |
    24 |
    25 |
    26 | 27 |
    28 |
    29 |
    30 | 31 |
    32 |
    33 |
    34 |
    35 | 36 | 52 | 53 | 54 | 55 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /etc/mockups/settings-mockup/style.css: -------------------------------------------------------------------------------- 1 | .top { 2 | border-bottom: 1px solid #cfcfcf; 3 | position: absolute; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | height: 40px; 8 | } 9 | 10 | .top h1 { 11 | font-size: 22px; 12 | margin-top: 7px; 13 | margin-left: 20px; 14 | } 15 | 16 | img.avatar { 17 | position: absolute; 18 | top: 10%; 19 | right: 20px; 20 | border-radius: 50px; 21 | width: 30px; 22 | height: 30px; 23 | cursor: pointer; 24 | } 25 | 26 | #settings { 27 | position: absolute; 28 | top: 40px; 29 | left: 0px; 30 | right: 0px; 31 | width: 500px; 32 | height: 90%; 33 | margin: auto; 34 | } 35 | 36 | #icon-username { 37 | position: absolute; 38 | top: 30px; 39 | left: 0px; 40 | right: 0px; 41 | width: 50%; 42 | margin: auto; 43 | } 44 | 45 | #icon { 46 | position: absolute; 47 | width: 100px; 48 | height: 150px; 49 | border: 1px solid black; 50 | } 51 | 52 | #icon img { 53 | position: absolute; 54 | top: 0px; 55 | left: 0px; 56 | height: 100px; 57 | width: 100px; 58 | } 59 | 60 | #icon span { 61 | position: absolute; 62 | top: 100px; 63 | width: 100%; 64 | padding: 5px 10px; 65 | border-top: 1px solid black; 66 | } 67 | 68 | #icon-username h2 { 69 | position: absolute; 70 | top: -20px; 71 | left: 120px; 72 | } 73 | 74 | #settings #form { 75 | position: absolute; 76 | top: 225px; 77 | left: 0px; 78 | right: 0px; 79 | width: 90%; 80 | margin: auto; 81 | } 82 | 83 | #form label { 84 | width: 150px; 85 | margin: 0px 20px 30px 0px; 86 | text-align: right; 87 | } 88 | 89 | #form select { 90 | width: 174px; 91 | } 92 | 93 | #form p:nth-child(3) select { 94 | width: 55.5px !important; 95 | } 96 | 97 | #settings button { 98 | position: absolute; 99 | bottom: 20px; 100 | left: 0px; 101 | right: 0px; 102 | width: 30%; 103 | margin: auto; 104 | color: white; 105 | background-color: #0084ff; 106 | border: 1px solid #0084ff; 107 | border-radius: 5px; 108 | padding: 10px 20px; 109 | } 110 | -------------------------------------------------------------------------------- /etc/mockups/login-mockups/password_login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Ting Mockup 6 | 7 | 8 | 9 | 10 | 11 |
    12 |

    Ting

    13 |
    14 | 15 |
    16 |
    17 |
      18 |
    • ting
    • 19 |
    20 |
    21 | 22 |
    23 |
    24 |
    25 |
    26 | 27 |
    28 |
    29 |
    30 | 31 |
    32 |
    33 |
    34 |
    35 | 36 | 51 | 52 | 53 | 54 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /etc/mockups/login-mockups/style.css: -------------------------------------------------------------------------------- 1 | #username-set input[type='text'], #username-set input[type='password'] 2 | { 3 | margin-top: 5px; 4 | margin-left: 220px; 5 | padding: 0px 10px; 6 | display: block; 7 | line-height: 30px; 8 | width: 175px; 9 | border-radius: 5px; 10 | outline: none; 11 | } 12 | 13 | #username-set input[type='submit'] 14 | { 15 | margin-top: 15px; 16 | margin-bottom: 10px; 17 | padding: 7px 10px; 18 | color: white; 19 | background-color: #0084ff; 20 | border: solid #0084ff; 21 | border-radius: 5px; 22 | } 23 | 24 | .top { 25 | border-bottom: 1px solid #cfcfcf; 26 | position: absolute; 27 | top: 0; 28 | left: 0; 29 | right: 0; 30 | height: 40px; 31 | } 32 | 33 | .top h1 { 34 | font-size: 22px; 35 | margin-top: 7px; 36 | margin-left: 20px; 37 | } 38 | 39 | .app { 40 | position: absolute; 41 | top: 40px; 42 | left: 0; 43 | right: 0; 44 | bottom: 0; 45 | } 46 | 47 | .nicklist { 48 | position: absolute; 49 | left: 0; 50 | top: 0; 51 | bottom: 0; 52 | width: 20%; 53 | } 54 | 55 | #online-list { 56 | margin: 10px 0 0 0; 57 | font-size: 17px; 58 | padding: 0; 59 | height: 50px; 60 | line-height: 40px; 61 | cursor: pointer; 62 | } 63 | 64 | #online-list span { 65 | display: block; 66 | margin-left: 45px; 67 | } 68 | 69 | #online-list li { 70 | padding: 5px 10px; 71 | height: 50px; 72 | cursor: pointer; 73 | } 74 | 75 | #online-list li.active { 76 | background-color: #0084ff; 77 | color: white; 78 | position: relative; 79 | } 80 | 81 | .chat { 82 | position: absolute; 83 | left: 20%; 84 | top: 0; 85 | right: 0; 86 | bottom: 0; 87 | border-left: 1px solid #8d8d8d; 88 | } 89 | 90 | #message input { 91 | height: 100%; 92 | } 93 | 94 | .textarea { 95 | position: absolute; 96 | bottom: 0; 97 | height: 50px; 98 | left: 0; 99 | right: 0; 100 | border-top: 1px solid #ddd; 101 | } 102 | 103 | .history { 104 | position: absolute; 105 | top: 0; 106 | left: 0; 107 | right: 0; 108 | bottom: 50px; 109 | } 110 | 111 | .history-wrapper { 112 | position: absolute; 113 | bottom: 0; 114 | left: 0; 115 | right: 0; 116 | top: 0; 117 | overflow-y: scroll; 118 | } 119 | 120 | form + a { 121 | display: block; 122 | margin-left: 10px; 123 | margin-bottom: 9px; 124 | } 125 | -------------------------------------------------------------------------------- /etc/mockups/topbar-mockup/style.css: -------------------------------------------------------------------------------- 1 | .top { 2 | border-bottom: 1px solid #cfcfcf; 3 | position: absolute; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | height: 40px; 8 | } 9 | 10 | .top h1 { 11 | font-size: 22px; 12 | margin-top: 7px; 13 | margin-left: 20px; 14 | } 15 | 16 | .app { 17 | position: absolute; 18 | top: 40px; 19 | left: 0; 20 | right: 0; 21 | bottom: 0; 22 | } 23 | 24 | .nicklist { 25 | position: absolute; 26 | left: 0; 27 | top: 0; 28 | bottom: 0; 29 | width: 20%; 30 | } 31 | 32 | #online-list { 33 | margin: 10px 0 0 0; 34 | font-size: 17px; 35 | padding: 0; 36 | height: 50px; 37 | line-height: 40px; 38 | cursor: pointer; 39 | } 40 | 41 | #online-list span { 42 | display: block; 43 | margin-left: 45px; 44 | } 45 | 46 | #online-list li { 47 | padding: 5px 10px; 48 | height: 50px; 49 | cursor: pointer; 50 | } 51 | 52 | #online-list li.active { 53 | background-color: #0084ff; 54 | color: white; 55 | position: relative; 56 | } 57 | 58 | .chat { 59 | position: absolute; 60 | left: 20%; 61 | top: 0; 62 | right: 0; 63 | bottom: 0; 64 | border-left: 1px solid #8d8d8d; 65 | } 66 | 67 | #message input { 68 | height: 100%; 69 | } 70 | 71 | .textarea { 72 | position: absolute; 73 | bottom: 0; 74 | height: 50px; 75 | left: 0; 76 | right: 0; 77 | border-top: 1px solid #ddd; 78 | } 79 | 80 | .history { 81 | position: absolute; 82 | top: 0; 83 | left: 0; 84 | right: 0; 85 | bottom: 50px; 86 | } 87 | 88 | .history-wrapper { 89 | position: absolute; 90 | bottom: 0; 91 | left: 0; 92 | right: 0; 93 | top: 0; 94 | overflow-y: scroll; 95 | } 96 | 97 | img.avatar { 98 | position: absolute; 99 | top: 10%; 100 | right: 20px; 101 | border-radius: 50px; 102 | width: 30px; 103 | height: 30px; 104 | margin-right: 5px; 105 | cursor: pointer; 106 | } 107 | 108 | #topbar { 109 | position: absolute; 110 | top: 50px; 111 | right: 27px; 112 | height: 90px; 113 | background-color: LightSlateGray; 114 | border-radius: 5px; 115 | display: none; 116 | } 117 | 118 | #topbar:before { 119 | content: ''; 120 | position: absolute; 121 | top: -7px; 122 | right: 3px; 123 | border-left: 10px solid transparent; 124 | border-right: 10px solid transparent; 125 | border-bottom: 10px solid LightSlateGray; 126 | } 127 | 128 | #topbar span { 129 | display: block; 130 | margin: 15px 10px; 131 | color: white; 132 | } 133 | -------------------------------------------------------------------------------- /client/message/__tests__/view-test.js: -------------------------------------------------------------------------------- 1 | const React = require('react'); 2 | const ReactDOM = require('react-dom'); 3 | const TestUtils = require('react-dom/test-utils'); 4 | 5 | jest.dontMock('../view.jsx'); 6 | 7 | const Message = require('../view.jsx'); 8 | 9 | describe('Message', function() { 10 | function renderAndGetNode(xml, node) { 11 | const reactNode = TestUtils.renderIntoDocument(xml); 12 | const domNode = TestUtils.findRenderedDOMComponentWithTag( 13 | reactNode, 14 | node 15 | ); 16 | 17 | return domNode; 18 | } 19 | 20 | function renderMessage(username, own, message_content, typing, message_type) { 21 | return renderAndGetNode( 22 | , 27 | 'li' 28 | ); 29 | } 30 | 31 | it('renders a message', function() { 32 | const li = renderMessage( 33 | 'hilbert', 34 | false, 35 | 'Wir müssen wissen — wir werden wissen!', 36 | false, 37 | 'text' 38 | ); 39 | 40 | expect(li.textContent).toContain('hilbert'); 41 | expect(ReactDOM.findDOMNode(li).className).toContain('other'); 42 | expect(ReactDOM.findDOMNode(li).className).not.toContain('typing'); 43 | }); 44 | 45 | it('distinguishes your own messages', function() { 46 | const li = renderMessage( 47 | 'hilbert', 48 | true, 49 | 'Wir müssen wissen — wir werden wissen!', 50 | false, 51 | 'text' 52 | ); 53 | 54 | expect(ReactDOM.findDOMNode(li).className).toContain('self'); 55 | }); 56 | 57 | it('distinguishes messages that are being typed', function() { 58 | var li = renderMessage( 59 | 'hilbert', 60 | true, 61 | 'Wir müssen wissen — wir werden wissen!', 62 | true, 63 | 'text' 64 | ); 65 | 66 | expect(ReactDOM.findDOMNode(li).className).toContain('typing'); 67 | 68 | var link = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHgAAAB4CAMAAAAOusbgAAAAG1BMVEX///8AAACdnZ3X19eZmZnq6ur7+/vOzs7x8fHjK9NyAAAARElEQVRoge3NiQmAMBAAsNrr4/4TC9YNlBNKskBKAT42W7317LgdS2THVSzeLu6xnCN7foy/YgAAAAAAAAAAAAAAgJcuaoEAp2NAe+UAAAAASUVORK5CYII='; 69 | 70 | li = renderMessage( 71 | 'hilbert', 72 | true, 73 | link, 74 | true, 75 | 'text' 76 | ); 77 | 78 | expect(ReactDOM.findDOMNode(li).className).toContain('typing'); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /etc/mockups/settings-mockup/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Ting Mockup 6 | 7 | 8 | 9 | 10 | 11 |
    12 |

    Ting

    13 | img 14 |
    15 | 16 |
    17 |
    18 |

    dionyziz

    19 | 20 |
    21 | img 22 | Αλλαγή της εικόνας μου 23 |
    24 |
    25 | 26 |
    27 |

    28 | 29 | 30 |

    31 |

    32 | 33 | 34 |

    35 | 36 |

    37 | 38 | 42 | 43 | 47 | 48 | 52 |

    53 | 54 |

    55 | 56 | 61 |

    62 | 63 |

    64 | 65 | 69 |

    70 |
    71 | 72 | 73 |
    74 | 75 | 76 | 77 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Ting is a chat platform. 2 | 3 | * To learn what the vision for ting is, see /etc/spec/SPECIFICATION.md. 4 | * For the technical architecture, see /etc/spec/ARCHITECTURE.md. 5 | * We have a bug database. Ask for access to our trello board. Pick a bug and 6 | fix it. 7 | * We use [ting.gr/dev](http://ting.gr/dev) and a Facebook chat for development 8 | team discussions. Ask for access. 9 | 10 | Running Ting 11 | ============ 12 | 1. git clone 13 | 2. Set up a MySQL database 14 | 3. Make a copy of `config/common.json` into `config/local.json` and add your 15 | settings. 16 | 4. Install NodeJS and the dependencies required to run Django installation by 17 | running `sudo apt-get install python-dev libmysqlclient-dev libffi-dev 18 | python-pip nodejs nodejs-legacy`. [Install 19 | yarn](https://yarnpkg.com/lang/en/docs/install/). 20 | 5. Make a virtual python environment using `virtualenv venv` on the API/ folder. 21 | We recommend this because the API will use its own copies of python and of the 22 | required dependencies, so you can update your libraries in your system 23 | without worrying that you might "break" the API. 24 | In order to use the virtual environment you only have to run `source venv/bin/activate` 25 | when you want to run python or pip. To deactivate it you can just run `deactivate` 26 | or close the terminal. 27 | 6. Run `pip install MySQL-python`. 28 | 7. Go to API/ and run `pip install -r requirements.txt` to install all the dependencies of 29 | Django server. 30 | 8. Run the Django server using `python manage.py runserver` inside the `API` 31 | folder. If it asks you to run migrations, do it. 32 | 9. Install the required dependencies for the node server and the client by 33 | running `yarn install` in client/ and realtime/. 34 | 10. Run the node service using `node server.js` or `forever start server.js` inside the 35 | `realtime` folder. 36 | 11. Build the client-side bundle with `yarn build` inside the `client` 37 | folder. Or run `yarn build --watch` if you plan to edit the client-side 38 | source. 39 | 12. Set up nginx to statically serve the `client` folder. 40 | 41 | Contributing 42 | ============ 43 | * Work on your own fork. 44 | * Never push to the main repo. Always create a feature branch and pull request. 45 | * You need an LGTM to merge. All review comments must be resolved even if you 46 | have an LGTM. 47 | * A pull request can be merged by the author or by the reviewer giving the 48 | LGTM. 49 | * Make sure your commits are clean and atomic. Commit messages must be 50 | descriptive. Rebase if you have to. 51 | 52 | Authors 53 | ======= 54 | Alphabetically. 55 | 56 | * Aleksis Brezas 57 | * Antonis Skarlatos 58 | * Carolina Alexiou 59 | * Dimitris Lamprinos 60 | * Dionysis Zindros 61 | * Eleni Lixourioti 62 | * Kostis Karantias 63 | * Petros Angelatos 64 | * Vitalis Salis 65 | -------------------------------------------------------------------------------- /client/app.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'), 2 | ReactDOM = require('react-dom'), 3 | i18n = require('i18next'), 4 | io = require('socket.io-client'), 5 | _ = require('lodash'), 6 | config = require('./config.jsx'), 7 | Route = require('react-router-dom').Route, 8 | HashRouter = require('react-router-dom').HashRouter, 9 | Switch = require('react-router-dom').Switch, 10 | TopBar = require('./topbar.jsx'), 11 | Ting = require('./ting.jsx'), 12 | Settings = require('./settings.jsx'); 13 | 14 | require('bootstrap'); 15 | require('bootstrap/dist/css/bootstrap.css'); 16 | 17 | class App extends React.Component { 18 | state = { 19 | username: null, 20 | people: [], 21 | }; 22 | 23 | _socket = null; 24 | 25 | componentWillMount() { 26 | const URL = window.location.hostname + ':' + config.port; 27 | this._socket = io.connect(URL, {secure: config.websocket.secure}); 28 | } 29 | 30 | updateUsername = (username) => { 31 | this.setState({username}); 32 | }; 33 | 34 | onLoginIntention = (username, people) => { 35 | this.setState({username, people}); 36 | }; 37 | 38 | removeListener = (listener, func) => { 39 | this._socket.removeListener(listener, func); 40 | }; 41 | 42 | addListener = (listener, func) => { 43 | this._socket.on(listener, func); 44 | }; 45 | 46 | render() { 47 | return ( 48 | 49 | 50 | ( 51 | 58 | )} /> 59 | ( 60 | 61 | )} /> 62 | 63 | 64 | ); 65 | } 66 | } 67 | 68 | i18n.init( 69 | { 70 | lng: 'el', 71 | //debug: true, 72 | resources: { 73 | en: { 74 | translation: require('./locales/en.json'), 75 | }, 76 | el: { 77 | translation: require('./locales/el.json'), 78 | } 79 | } 80 | }, 81 | () => { 82 | ReactDOM.render(, document.getElementsByClassName('ting')[0]); 83 | } 84 | ); 85 | -------------------------------------------------------------------------------- /API/chat/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.shortcuts import get_object_or_404 4 | from django.http import HttpResponse, HttpResponseBadRequest, QueryDict 5 | from django.views.generic import View 6 | from .utils import datetime_to_timestamp 7 | 8 | from .models import Channel, Message 9 | from .forms import MessageCreationForm, MessagePatchForm 10 | from django.conf import settings 11 | 12 | 13 | def privileged(f): 14 | """ 15 | Do a password check for privileged operations 16 | """ 17 | def check(self, request, *args, **kwargs): 18 | if settings.PASS != request.META.get('HTTP_AUTHORIZATION'): 19 | return HttpResponse('Unauthorized', status=401) 20 | return f(self, request, *args, **kwargs) 21 | return check 22 | 23 | 24 | class MessageView(View): 25 | @privileged 26 | def post(self, request, type, target, *args, **kwargs): 27 | # currently `type` is always 'channel' 28 | channel = get_object_or_404(Channel, name=target) 29 | 30 | form = MessageCreationForm(request.POST) 31 | 32 | if not form.is_valid(): 33 | return HttpResponseBadRequest(str(form.errors)) 34 | 35 | form.channel = channel 36 | message = form.save() 37 | 38 | return HttpResponse(message.id) 39 | 40 | @privileged 41 | def patch(self, request, id, *args, **kwargs): 42 | qdict = QueryDict(request.body) 43 | 44 | message = Message.objects.get(pk=id) 45 | 46 | form = MessagePatchForm(qdict) 47 | 48 | if not form.is_valid() or not message.typing: 49 | return HttpResponseBadRequest(str(form.errors)) 50 | 51 | form.save(message) 52 | 53 | return HttpResponse(status=204) 54 | 55 | def get(self, request, type, target, *args, **kwargs): 56 | lim = request.GET.get('lim', 100) 57 | 58 | # currently `type` is always 'channel' 59 | channel = get_object_or_404(Channel, name=target) 60 | 61 | messages = Message.objects.values( 62 | 'message_content', 'username', 'datetime_start', 'typing', 'id', 63 | 'datetime_sent', 'message_type' 64 | ).filter(channel=channel).order_by('-id')[:lim] 65 | 66 | # convert datetime_start to UTC epoch milliseconds 67 | for message in messages: 68 | message['datetime_start'] = datetime_to_timestamp(message['datetime_start']) 69 | if message['datetime_sent']: 70 | message['datetime_sent'] = datetime_to_timestamp(message['datetime_sent']) 71 | 72 | messages_json = json.dumps(list(messages)) 73 | 74 | return HttpResponse(messages_json, content_type='application/json') 75 | 76 | @privileged 77 | def delete(self, request, id, *args, **kwargs): 78 | message = get_object_or_404(Message, pk=id) 79 | 80 | message.delete() 81 | return HttpResponse(status=204) 82 | 83 | 84 | class ChannelView(View): 85 | def post(self, request, *args, **kwargs): 86 | channel = Channel(name=request.POST['name']) 87 | channel.save() 88 | 89 | return HttpResponse(status=204) 90 | 91 | def get(self, request, *args, **kwargs): 92 | queryset = Channel.objects.values('name') 93 | channel = get_object_or_404(queryset, name=request.GET['name']) 94 | 95 | return HttpResponse( 96 | json.dumps(channel), 97 | content_type='application/json' 98 | ) 99 | -------------------------------------------------------------------------------- /client/message/form.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'), 2 | ReactDOM = require('react-dom'), 3 | i18n = require('i18next'); 4 | 5 | class MessageForm extends React.Component { 6 | state = { 7 | message: '' 8 | }; 9 | 10 | _MIN_UPDATE_WHILE_TYPING = 2000; 11 | _MIN_UPDATE_WHEN_STOPPED = 500; 12 | _lastUpdate = 0; 13 | _lastUpdateTimeout = null; 14 | _imageData = null; 15 | _typeLastMessage = null; 16 | 17 | handleSubmit = (event) => { 18 | event.preventDefault(); 19 | 20 | var message = this.state.message; 21 | 22 | if (message.trim().length > 0) { 23 | if (!this.props.onMessageSubmit(message, 'text')) { 24 | return; 25 | } 26 | 27 | ReactDOM.findDOMNode(this.refs.inputField).value = ''; 28 | } 29 | 30 | this.setState({ 31 | message: '' 32 | }); 33 | }; 34 | 35 | onLogin = () => { 36 | ReactDOM.findDOMNode(this.refs.inputField).focus(); 37 | }; 38 | 39 | handleChange = (event) => { 40 | var message = event.target.value; 41 | this._typeLastMessage = 'text'; 42 | 43 | if (message.trim().length > 0) { 44 | if (this.state.message.trim() == '') { 45 | this.props.onStartTyping(message, 'text'); 46 | } 47 | else if (Date.now() - this._lastUpdate >= this._MIN_UPDATE_WHILE_TYPING) { 48 | this.props.onTypingUpdate(message); 49 | this._lastUpdate = Date.now(); 50 | clearTimeout(this._lastUpdateTimeout); 51 | } 52 | else { 53 | clearTimeout(this._lastUpdateTimeout); 54 | this._lastUpdateTimeout = setTimeout(() => { 55 | this.props.onTypingUpdate(message); 56 | }, this._MIN_UPDATE_WHEN_STOPPED); 57 | } 58 | } 59 | else if (this.state.message.trim().length > 0) { // message was deleted 60 | this.props.onTypingUpdate(message); 61 | } 62 | this.setState({message}); 63 | }; 64 | 65 | onImageLoaded = (event) => { 66 | this._imageData = event.target.result; 67 | this.props.onStartTyping(this._imageData, 'image'); 68 | }; 69 | 70 | onStartTypingResponse = (messageid) => { 71 | if (this._typeLastMessage == 'image') { 72 | this.props.onMessageSubmit(this._imageData, 'image'); 73 | } 74 | }; 75 | 76 | loadImage = (src) => { 77 | var reader = new FileReader(); 78 | reader.onload = this.onImageLoaded; 79 | reader.readAsDataURL(src); 80 | }; 81 | 82 | handleDrop = (event) => { 83 | event.preventDefault(); 84 | this._typeLastMessage = 'image'; 85 | var data = event.dataTransfer.files[0]; 86 | this.loadImage(data); 87 | }; 88 | 89 | render() { 90 | return ( 91 |
    92 |
    94 | 101 |
    102 |
    103 | ); 104 | } 105 | } 106 | 107 | module.exports = MessageForm; 108 | -------------------------------------------------------------------------------- /API/API/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for API project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.8.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.8/ref/settings/ 11 | """ 12 | 13 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 14 | import os 15 | import json 16 | import sys 17 | 18 | config = json.load(open('../config/common.json')) 19 | 20 | if os.path.isfile('../config/local.json'): 21 | config = json.load(open('../config/local.json')) 22 | 23 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 24 | 25 | 26 | # Quick-start development settings - unsuitable for production 27 | # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ 28 | 29 | # SECURITY WARNING: keep the secret key used in production secret! 30 | SECRET_KEY = config['django']['secret_key'] 31 | 32 | PASS = config['password'] 33 | 34 | # SECURITY WARNING: don't run with debug turned on in production! 35 | DEBUG = config['django']['debug'] 36 | 37 | ALLOWED_HOSTS = config['django']['allowed_hosts'] 38 | 39 | 40 | # Application definition 41 | 42 | INSTALLED_APPS = ( 43 | 'django.contrib.admin', 44 | 'django.contrib.auth', 45 | 'django.contrib.contenttypes', 46 | 'django.contrib.sessions', 47 | 'django.contrib.messages', 48 | 'django.contrib.staticfiles', 49 | 'chat', 50 | ) 51 | 52 | MIDDLEWARE_CLASSES = ( 53 | 'django.contrib.sessions.middleware.SessionMiddleware', 54 | 'django.middleware.common.CommonMiddleware', 55 | 'django.middleware.csrf.CsrfViewMiddleware', 56 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 57 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 58 | 'django.contrib.messages.middleware.MessageMiddleware', 59 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 60 | 'django.middleware.security.SecurityMiddleware', 61 | ) 62 | 63 | ROOT_URLCONF = 'API.urls' 64 | 65 | TEMPLATES = [ 66 | { 67 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 68 | 'DIRS': [], 69 | 'APP_DIRS': True, 70 | 'OPTIONS': { 71 | 'context_processors': [ 72 | 'django.template.context_processors.debug', 73 | 'django.template.context_processors.request', 74 | 'django.contrib.auth.context_processors.auth', 75 | 'django.contrib.messages.context_processors.messages', 76 | ], 77 | }, 78 | }, 79 | ] 80 | 81 | WSGI_APPLICATION = 'API.wsgi.application' 82 | 83 | 84 | # Database 85 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 86 | 87 | DATABASES = { 88 | 'default': { 89 | 'ENGINE': 'django.db.backends.mysql', 90 | 'NAME': config['django']['database']['name'], 91 | 'USER': config['django']['database']['user'], 92 | 'PASSWORD': config['django']['database']['password'], 93 | 'HOST': config['django']['database']['host'] 94 | } 95 | } 96 | 97 | # checks if the tests are running 98 | if 'test' in sys.argv or 'test_coverage' in sys.argv: 99 | DATABASES['default']['ENGINE'] = 'django.db.backends.sqlite3' 100 | 101 | 102 | # Internationalization 103 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 104 | 105 | LANGUAGE_CODE = 'en-us' 106 | 107 | TIME_ZONE = 'UTC' 108 | 109 | USE_I18N = True 110 | 111 | USE_L10N = True 112 | 113 | USE_TZ = True 114 | 115 | 116 | # Static files (CSS, JavaScript, Images) 117 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 118 | 119 | STATIC_URL = '/static/' 120 | -------------------------------------------------------------------------------- /client/settings.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'), 2 | Avatar = require('./avatar.jsx'), 3 | Select = require('./select.jsx'), 4 | NavLink = require('react-router-dom').NavLink, 5 | i18n = require('i18next'); 6 | 7 | require('./css/settings.css'); 8 | 9 | class Settings extends React.Component { 10 | state = { 11 | sex: [i18n.t('gender.boy'), i18n.t('gender.girl'), i18n.t('gender.undefined')], 12 | cities: ['Αθήνα', 'Θεσσαλονίκη'] 13 | }; 14 | 15 | handleKeyDown = (e) => { 16 | if (e.keyCode == 27) { 17 | e.preventDefault(); 18 | this.props.history.push('/'); 19 | } 20 | }; 21 | 22 | componentWillMount() { 23 | document.addEventListener('keydown', this.handleKeyDown); 24 | } 25 | 26 | componentWillUnmount() { 27 | document.removeEventListener('keydown', this.handleKeyDown); 28 | } 29 | 30 | render() { 31 | var currentYear = new Date().getFullYear(); 32 | 33 | return ( 34 |
    35 |
    36 |
    37 | 38 | {i18n.t('settingsSet.changePic')} 39 |
    40 |

    {this.props.username}

    41 |
    42 | 43 |
    44 |
    45 | 46 |
    47 | 48 |
    49 |
    50 | 51 |
    52 | 53 |
    54 | 55 |
    56 |
    57 | 58 |
    59 | 60 |
    61 | 65 |
    66 |
    67 | 75 |
    76 |
    77 | 78 |
    79 | 80 |
    81 | 116 | 121 | 122 |
    123 |
    124 |
    125 | 126 | ); 127 | } 128 | } 129 | 130 | module.exports = LoginForm; 131 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: deps start open stop kill logs restart shell test 2 | 3 | UNAME_S := $(shell uname -s) 4 | USERNAME := $(shell whoami) 5 | IS_DOCKER_MEMBER := $(shell groups|grep docker) 6 | ROOT_NAME := $(shell basename $(shell pwd)) 7 | BOOT2DOCKER_IP := $(shell boot2docker ip 2> /dev/null) 8 | DOCKER_VERSION := $(shell docker version 2> /dev/null) 9 | DOCKER_COMPOSE_VERSION := $(shell docker-compose version 2> /dev/null) 10 | DOCKER_DOES_NOT_WORK := $(shell (docker info 1> /dev/null) 2>&1|grep -v WARNING) 11 | DOCKER_COMPOSE_DOES_NOT_WORK := $(shell docker-compose ps 2>&1|grep "client and server don't have same version") 12 | PORT_80_USED := $(shell netstat -lnt|awk '$$4 ~ ".80"') 13 | PORT_8080_USED := $(shell netstat -lnt|awk '$$4 ~ ".8080"') 14 | 15 | ifndef BOOT2DOCKER_IP 16 | DOCKER_IP := 127.0.0.1 17 | else 18 | DOCKER_IP := $(BOOT2DOCKER_IP) 19 | endif 20 | 21 | ifeq ($(UNAME_S),Darwin) 22 | OPEN := open 23 | else 24 | ifeq ($(UNAME_S),Linux) 25 | OPEN := xdg-open 26 | endif 27 | endif 28 | 29 | ifndef COMPOSE_FILE 30 | COMPOSE_FILE := development.yml 31 | export COMPOSE_FILE 32 | endif 33 | 34 | IS_FRONT_UP := $(shell docker-compose -f $(COMPOSE_FILE) ps front|grep Up) 35 | IS_REALTIME_UP := $(shell docker-compose -f $(COMPOSE_FILE) ps realtime|grep Up) 36 | 37 | deps: 38 | ifeq ($(UNAME_S),Linux) 39 | ifneq ($(USERNAME),root) 40 | ifndef IS_DOCKER_MEMBER 41 | @echo "ERROR: Are you in the docker group?" 42 | @echo "You can try:" 43 | @echo " sudo usermod -aG docker $(USERNAME)" 44 | exit 1 45 | endif 46 | endif 47 | 48 | ifndef IS_FRONT_UP 49 | ifdef PORT_80_USED 50 | @echo "ERROR: We need to bind to port 80, but something is using it. Please free it up and rerun this." 51 | exit 1 52 | endif 53 | endif 54 | 55 | ifndef IS_REALTIME_UP 56 | ifdef PORT_8080_USED 57 | @echo "ERROR: We need to bind to port 8080, but something is using it. Please free it up and rerun this." 58 | exit 1 59 | endif 60 | endif 61 | endif 62 | ifndef DOCKER_VERSION 63 | @echo "ERROR: You need to install Docker first. Exiting." 64 | exit 1 65 | endif 66 | ifndef DOCKER_COMPOSE_VERSION 67 | @echo "ERROR: You need to install docker-compose first. Exiting." 68 | exit 1 69 | endif 70 | ifdef DOCKER_DOES_NOT_WORK 71 | @echo "ERROR: Docker is not installed correctly." 72 | @echo "You may need boot2docker on OSX, or to start the docker service on Linux." 73 | exit 1 74 | endif 75 | ifdef DOCKER_COMPOSE_DOES_NOT_WORK 76 | @echo "ERROR: Your docker version is incompatible with your docker-compose version." 77 | ifdef BOOT2DOCKER_IP 78 | @echo "You can try:" 79 | @echo " brew update && brew upgrade docker docker-compose boot2docker" 80 | @echo " boot2docker upgrade && boot2docker up" 81 | else 82 | @echo "It's recommended that you install both on the latest version." 83 | @echo "Remove all docker packages and docker-compose executables and consult these links:" 84 | @echo " https://docs.docker.com/installation/ubuntulinux/#installation" 85 | @echo " https://docs.docker.com/compose/install/" 86 | endif 87 | exit 1 88 | endif 89 | 90 | build: deps $(COMPOSE_FILE) 91 | docker-compose build 92 | 93 | start: deps $(COMPOSE_FILE) 94 | docker-compose up -d 95 | 96 | open: deps start 97 | $(OPEN) "http://$(DOCKER_IP):80" 98 | 99 | stop: deps 100 | docker-compose stop 101 | 102 | kill: deps 103 | docker-compose kill 104 | 105 | logs: deps 106 | ifdef TARGET 107 | docker-compose logs $(TARGET) 108 | else 109 | docker-compose logs 110 | endif 111 | 112 | restart: deps 113 | ifdef TARGET 114 | docker-compose restart -t 0 $(TARGET) 115 | else 116 | docker-compose restart -t 0 117 | endif 118 | 119 | shell: deps start 120 | ifndef TARGET 121 | @echo "ERROR: 'make shell' should be used with a target." 122 | @echo "Example: make shell TARGET=api" 123 | exit 1 124 | endif 125 | docker exec -it $(shell docker-compose -f $(COMPOSE_FILE) ps -q $(TARGET)) /bin/bash 126 | 127 | test: deps start 128 | ifndef TARGET 129 | @echo "ERROR: 'make test' should be used with a target." 130 | @echo "Example: make test TARGET=api" 131 | exit 1 132 | endif 133 | ifeq ($(TARGET),api) 134 | docker exec -it $(shell docker-compose -f $(COMPOSE_FILE) ps -q $(TARGET)) python manage.py test chat 135 | else 136 | ifeq ($(TARGET),front) 137 | docker exec -it $(shell docker-compose -f $(COMPOSE_FILE) ps -q $(TARGET)) npm test 138 | else 139 | ifeq ($(TARGET),realtime) 140 | docker exec -it $(shell docker-compose -f $(COMPOSE_FILE) ps -q $(TARGET)) npm test 141 | else 142 | @echo "ERROR: Don't know how to test $(TARGET)." 143 | exit 1 144 | endif 145 | endif 146 | endif 147 | -------------------------------------------------------------------------------- /etc/mockups/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font: "Helvetica Neue",Helvetica,Arial,sans-serif; 3 | } 4 | .top { 5 | border-bottom: 1px solid #cfcfcf; 6 | position: absolute; 7 | top: 0; 8 | left: 0; 9 | right: 0; 10 | height: 47px; 11 | } 12 | .app { 13 | position: absolute; 14 | top: 47px; 15 | left: 0; 16 | right: 0; 17 | bottom: 0; 18 | } 19 | .nicklist { 20 | position: absolute; 21 | left: 0; 22 | top: 0; 23 | bottom: 0; 24 | width: 20%; 25 | } 26 | .chat { 27 | position: absolute; 28 | left: 20%; top: 0; 29 | right: 0; 30 | bottom: 0; 31 | border-left: 1px solid #8d8d8d; 32 | } 33 | .history { 34 | position: absolute; 35 | top: 0; 36 | left: 0; 37 | right: 0; 38 | bottom: 50px; 39 | } 40 | .history ul { 41 | width: 100%; 42 | } 43 | .history li { 44 | padding: 4px 0; 45 | width: 100%; 46 | } 47 | .history li.first { 48 | border-top: 1px solid #ddd; 49 | padding-top: 8px; 50 | margin-top: 4px; 51 | } 52 | .history img.avatar { 53 | border-radius: 50px; 54 | width: 20px; 55 | height: 20px; 56 | } 57 | .textarea { 58 | position: absolute; 59 | bottom: 0; 60 | height: 50px; 61 | left: 0; 62 | right: 0; 63 | border-top: 1px solid #ddd; 64 | } 65 | .history-wrapper { 66 | position: absolute; 67 | bottom: 0; 68 | left: 0; 69 | right: 0; 70 | top: 0; 71 | overflow-y: scroll; 72 | } 73 | .chat ul { 74 | position: absolute; 75 | bottom: 0; 76 | max-height: 100%; 77 | padding: 10px 2px 0 12px; 78 | } 79 | .chat ul div { 80 | display: inline; 81 | margin: 2px 8px; 82 | padding: 5px 9px; 83 | border-radius: 10px; 84 | background-color: #f1f0f0; 85 | color: black; 86 | position: relative; 87 | } 88 | .chat ul div.self { 89 | background-color: #0084ff; 90 | color: white; 91 | } 92 | .chat ul div.self a { 93 | color: white; 94 | text-decoration: underline; 95 | } 96 | .chat ul div:after { 97 | content: "◀"; 98 | display: block; 99 | position: absolute; 100 | left: -8px; 101 | top: 5px; 102 | color: #f1f0f0; 103 | font-weight: bold; 104 | } 105 | .chat ul li.self div { 106 | margin-right: 10px; 107 | } 108 | .chat ul li.self { 109 | text-align: right; 110 | } 111 | .chat ul li.self img { 112 | float: right; 113 | } 114 | .chat ul li.self strong { 115 | display: none; 116 | } 117 | .chat ul li.self div:after { 118 | content: "►"; 119 | display: block; 120 | position: absolute; 121 | right: -8px; 122 | top: 5px; 123 | font-weight: bold; 124 | } 125 | .chat ul div.self:after { 126 | color: #0084ff; 127 | } 128 | .chat ul li.typing { 129 | opacity: 0.5; 130 | } 131 | @keyframes flash { 132 | 0% { 133 | opacity: 0.5; 134 | } 135 | 50% { 136 | opacity: 1; 137 | } 138 | 100% { 139 | opacity: 0.5; 140 | } 141 | } 142 | .chat ul li.typing div { 143 | animation: flash 1s infinite; 144 | } 145 | #msg { 146 | height: 100%; 147 | } 148 | #msg input { 149 | border: 0px; 150 | height: 100%; 151 | } 152 | .top h1 { 153 | font-size: 22px; 154 | margin-top: 11px; 155 | margin-left: 20px; 156 | } 157 | #username-set input { 158 | margin-bottom: 10px; 159 | margin-top: 10px; 160 | } 161 | #username { 162 | margin-right: auto; 163 | margin-left: auto; 164 | width: 30%; 165 | } 166 | ul { 167 | list-style-type: none; 168 | } 169 | #recent-list { 170 | margin: 10px 0 0 0; 171 | padding: 0; 172 | font-size: 17px; 173 | } 174 | #recent-list li { 175 | padding: 5px 10px; 176 | height: 50px; 177 | line-height: 40px; 178 | cursor: pointer; 179 | } 180 | #recent-list li:hover { 181 | background-color: #eee; 182 | } 183 | #recent-list li img { 184 | width: 35px; 185 | height: 35px; 186 | border-radius: 50px; 187 | margin-right: 5px; 188 | float: left; 189 | margin-top: 5px; 190 | } 191 | #recent-list li.active { 192 | background-color: #0084ff; 193 | color: white; 194 | position: relative; 195 | } 196 | li.active:after { 197 | content: "▶"; 198 | color: #0084ff; 199 | display: block; 200 | position: absolute; 201 | right: -10px; 202 | top: 6px; 203 | z-index: 1; 204 | } 205 | #recent-list span { 206 | display: block; 207 | margin-left: 45px; 208 | } 209 | div.menu { 210 | float: right; 211 | margin: 4px 7px; 212 | } 213 | div.menu img.avatar { 214 | width: 29px; 215 | height: 29px; 216 | border-radius: 25px; 217 | margin: 4px 0; 218 | cursor: pointer; 219 | } 220 | div.menu span { 221 | font-weight: bold; 222 | position: relative; 223 | top: 1px; 224 | padding: 4px 17px 4px 3px; 225 | font-size: 90%; 226 | } 227 | div.menu .cog { 228 | width: 15px; 229 | border-radius: 25px; 230 | margin: 11px 3px; 231 | cursor: pointer; 232 | } 233 | -------------------------------------------------------------------------------- /client/css/index.css: -------------------------------------------------------------------------------- 1 | .top { 2 | border-bottom: 1px solid #cfcfcf; 3 | position: absolute; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | height: 40px; 8 | } 9 | .app { 10 | position: absolute; 11 | top: 40px; 12 | left: 0; 13 | right: 0; 14 | bottom: 0; 15 | } 16 | .nicklist { 17 | position: absolute; 18 | left: 0; 19 | top: 0; 20 | bottom: 0; 21 | width: 20%; 22 | overflow-y: auto; 23 | overflow-x: hidden; 24 | } 25 | .chat { 26 | position: absolute; 27 | left: 20%; top: 0; 28 | right: 0; 29 | bottom: 0; 30 | border-left: 1px solid #8d8d8d; 31 | } 32 | .history { 33 | position: absolute; 34 | top: 0; 35 | left: 0; 36 | right: 0; 37 | bottom: 50px; 38 | } 39 | .history li { 40 | padding: 4px 0; 41 | margin: 0; 42 | display: flex; 43 | align-items: center; 44 | } 45 | .history li:last-child { 46 | margin-bottom: 4px; 47 | } 48 | .history img.avatar { 49 | border-radius: 50px; 50 | width: 20px; 51 | height: 20px; 52 | min-width: 20px; 53 | margin-right: 5px; 54 | } 55 | .textarea { 56 | position: absolute; 57 | bottom: 0; 58 | height: 50px; 59 | left: 0; 60 | right: 0; 61 | border-top: 1px solid #ddd; 62 | } 63 | .history-wrapper { 64 | position: absolute; 65 | bottom: 0; 66 | left: 0; 67 | right: 0; 68 | top: 0; 69 | overflow-y: scroll; 70 | } 71 | .chat ul { 72 | position: absolute; 73 | bottom: 0; 74 | max-height: 100%; 75 | padding: 10px 2px 0 12px; 76 | width: 100%; 77 | } 78 | .chat ul div { 79 | margin: 0 0 0 12px; 80 | padding: 9px; 81 | border-radius: 16px; 82 | background-color: #f1f0f0; 83 | color: black; 84 | position: relative; 85 | line-height: 15px; 86 | min-width: 25px; 87 | word-wrap: break-word; 88 | cursor: text; 89 | } 90 | .chat ul .self div { 91 | background-color: #0084ff; 92 | color: white; 93 | } 94 | .chat ul .typing div { 95 | opacity: 0.5; 96 | } 97 | .chat .content_image { 98 | width: 100%; 99 | border-radius: 8px; 100 | } 101 | #message { 102 | height: 100%; 103 | } 104 | #message input { 105 | border: 0px; 106 | height: 100%; 107 | } 108 | .top h1 { 109 | font-size: 22px; 110 | margin-top: 7px; 111 | margin-left: 20px; 112 | } 113 | #username-set input { 114 | margin-bottom: 10px; 115 | margin-top: 10px; 116 | } 117 | #username-set input[type=text] { 118 | margin-right: auto; 119 | margin-left: auto; 120 | width: 30%; 121 | } 122 | ul { 123 | list-style-type: none; 124 | } 125 | .chat ul .self div a { 126 | color: white; 127 | text-decoration: underline; 128 | } 129 | .chat ul div:after { 130 | content: "◀"; 131 | display: block; 132 | position: absolute; 133 | left: -8px; 134 | top: calc(50% - 0.5em); 135 | color: #f1f0f0; 136 | font-weight: bold; 137 | } 138 | .chat ul .self div:after { 139 | color: #0084ff; 140 | } 141 | #online-list { 142 | margin: 10px 0 0 0; 143 | font-size: 17px; 144 | padding: 0; 145 | height: 50px; 146 | line-height: 40px; 147 | cursor: pointer; 148 | } 149 | #online-list span { 150 | display: block; 151 | margin-left: 45px; 152 | } 153 | #online-list li { 154 | padding: 5px 10px; 155 | height: 50px; 156 | line-height: 40px; 157 | cursor: pointer; 158 | } 159 | #online-list li img { 160 | width: 35px; 161 | height: 35px; 162 | border-radius: 50px; 163 | margin-right: 5px; 164 | float: left; 165 | margin-top: 5px; 166 | } 167 | #online-list li.active { 168 | background-color: #0084ff; 169 | color: white; 170 | position: relative; 171 | } 172 | #online-list li.active:after { 173 | content: "▶"; 174 | color: #0084ff; 175 | display: block; 176 | position: absolute; 177 | right: -10px; 178 | top: 6px; 179 | z-index: 1; 180 | } 181 | .chat .emoticon { 182 | position: relative; 183 | left: 2px; 184 | top: -5px; 185 | vertical-align: top; 186 | text-align: left; 187 | padding: 2px 7px 2px 4px; 188 | color: transparent; 189 | } 190 | .chat ul li.typing { 191 | opacity: 0.5; 192 | } 193 | @keyframes flash { 194 | 0% { 195 | opacity: 0.5; 196 | } 197 | 50% { 198 | opacity: 1; 199 | } 200 | 100% { 201 | opacity: 0.5; 202 | } 203 | } 204 | .chat ul li.typing div { 205 | animation: flash 2s infinite; 206 | } 207 | .top .topbar { 208 | position: absolute; 209 | top: 10%; 210 | right: 22px; 211 | } 212 | .topbar img.avatar { 213 | border-radius: 50px; 214 | width: 30px; 215 | height: 30px; 216 | cursor: pointer; 217 | } 218 | .topbar button { 219 | background-color: transparent; 220 | border-color: transparent; 221 | padding: 0; 222 | } 223 | .topbar #top-btn:hover, .topbar #top-btn:active { 224 | background-color: transparent; 225 | border-color: transparent; 226 | outline: none; 227 | box-shadow: none; 228 | } 229 | .topbar ul { 230 | margin-top: 5px; 231 | min-width: 20px; 232 | } 233 | .dropdown-menu:before { 234 | position: absolute; 235 | top: -7px; 236 | right: 9px; 237 | display: inline-block; 238 | border-right: 7px solid transparent; 239 | border-bottom: 7px solid #ccc; 240 | border-left: 7px solid transparent; 241 | border-bottom-color: rgba(0, 0, 0, 0.2); 242 | content: ''; 243 | } 244 | -------------------------------------------------------------------------------- /client/message/history.jsx: -------------------------------------------------------------------------------- 1 | const React = require('react'), 2 | ReactDOM = require('react-dom'), 3 | emoticons = require('emoticons'), 4 | Message = require('./view.jsx'), 5 | _ = require('lodash'), 6 | $ = require('jquery'), 7 | update = require('immutability-helper'); 8 | 9 | class History extends React.Component { 10 | state = { 11 | messages: {}, 12 | unread: 0, 13 | active: true, 14 | myUsername: null 15 | }; 16 | 17 | _wrapper = null; 18 | _title = document.title; 19 | _audio = new Audio('static/sounds/message_sound.mp3'); 20 | 21 | _scrollDown = () => { 22 | setTimeout(() => { 23 | this._wrapper.scrollTop = this._wrapper.scrollHeight; 24 | }, 30); 25 | }; 26 | 27 | _updateTitle = () => { 28 | var titlePrefix; 29 | 30 | if (this.state.active || this.state.unread == 0) { 31 | titlePrefix = ''; 32 | } 33 | else { 34 | titlePrefix = '(' + this.state.unread + ') '; 35 | } 36 | 37 | document.title = titlePrefix + this._title; 38 | }; 39 | 40 | deleteTypingMessage = (username) => { 41 | this.setState((prevState) => { 42 | let messages = prevState.messages; 43 | 44 | for (var id of Object.keys(messages)) { 45 | if (messages[id].username == username && messages[id].typing) { 46 | messages = update(messages, {$unset: [id]}); 47 | } 48 | } 49 | 50 | return {messages}; 51 | }); 52 | }; 53 | 54 | onUpdateTypingMessages = (messagesTyping) => { 55 | this.setState((prevState) => { 56 | let messages = prevState.messages; 57 | 58 | $.each(messagesTyping, (messageid, message) => { 59 | if (message.message_content.trim().length == 0) { 60 | messages = update(messages, {$unset: [messageid]}); 61 | } 62 | else if (message.target == this.props.channel) { 63 | if (messages[messageid]) { 64 | messages = update(messages, { 65 | [messageid]: {$merge: { 66 | message_content: message.message_content 67 | }} 68 | }); 69 | } 70 | else { 71 | messages = update(messages, { 72 | [messageid]: {$set: { 73 | message_content: message.message_content, 74 | username: message.username, 75 | target: message.target, 76 | id: parseInt(messageid), 77 | typing: true, 78 | message_type: message.message_type 79 | }} 80 | }); 81 | } 82 | } 83 | }); 84 | 85 | return {messages}; 86 | }); 87 | }; 88 | 89 | onHistoricalMessagesAvailable = (messages) => { 90 | this.setState({messages}); 91 | }; 92 | 93 | onLogin = (myUsername) => { 94 | this.setState({myUsername}); 95 | }; 96 | 97 | onMessage = (data) => { 98 | if (data.target == this.props.channel) { 99 | this.setState((prevState, currentProps) => { 100 | let messages = prevState.messages; 101 | 102 | messages = update(messages, { 103 | [data.messageid]: {$merge: { 104 | message_content: data.message_content, 105 | typing: false 106 | }} 107 | }); 108 | 109 | let unread = prevState.unread; 110 | if (!prevState.active && data.username != prevState.myUsername) { 111 | unread = prevState.unread + 1; 112 | this._audio.play(); 113 | } 114 | return { 115 | messages, 116 | unread 117 | }; 118 | }); 119 | } 120 | }; 121 | 122 | componentDidMount() { 123 | this._wrapper = ReactDOM.findDOMNode(this.refs.wrapper); 124 | 125 | $.getJSON('node_modules/emoticons/support/skype/emoticons.json', function(definition) { 126 | emoticons.define(definition); 127 | }); 128 | 129 | $(document).on({ 130 | show: () => { 131 | this.setState({ 132 | active: true, 133 | unread: 0 134 | }); 135 | }, 136 | hide: () => { 137 | this.setState({ 138 | active: false 139 | }); 140 | } 141 | }); 142 | } 143 | 144 | render() { 145 | const messageNodes = _.chain(this.state.messages) 146 | .values() 147 | .sortBy('id') 148 | .map(({id, username, message_content, typing, message_type}) => { 149 | return ( 150 | 156 | ); 157 | }) 158 | .value(); 159 | 160 | return ( 161 |
    162 |
    163 |
      164 | {messageNodes} 165 |
    166 |
    167 |
    168 | ); 169 | } 170 | 171 | componentDidUpdate() { 172 | this._updateTitle(); 173 | this._scrollDown(); 174 | } 175 | } 176 | 177 | module.exports = History; 178 | -------------------------------------------------------------------------------- /client/ting.jsx: -------------------------------------------------------------------------------- 1 | const UserList = require('./userlist.jsx'), 2 | LoginForm = require('./login.jsx'), 3 | History = require('./message/history.jsx'), 4 | MessageForm = require('./message/form.jsx'), 5 | React = require('react'), 6 | ReactDOM = require('react-dom'), 7 | Analytics = require('./analytics.js'), 8 | _ = require('lodash'), 9 | $ = require('jquery'), 10 | TopBar = require('./topbar.jsx'); 11 | 12 | require('./css/index.css'); 13 | 14 | class Ting extends React.Component { 15 | constructor(props) { 16 | super(props); 17 | const url = location.href, 18 | parts = url.split('/'); 19 | var [channel] = parts.slice(-1); 20 | 21 | if (channel == '' || channel == '?') { 22 | channel = 'ting'; 23 | } 24 | 25 | this.state = { 26 | channel, 27 | currentMessageId: null 28 | // TODO(dionyziz): race conditions and queues 29 | }; 30 | } 31 | 32 | onLogin = (username, people) => { 33 | this.refs.history.onLogin(username, people); 34 | this.refs.messageForm.onLogin(username, people); 35 | this.refs.userList.onLogin(username, people); 36 | this.refs.topBar.onLogin(username); 37 | this.props.onLoginIntention(username, people); 38 | 39 | // currently `type` is always 'channel' 40 | $.getJSON('/api/messages/channel/' + this.state.channel, (messages) => { 41 | const history = _.keyBy(messages, 'id'); 42 | 43 | this.refs.history.onHistoricalMessagesAvailable(history); 44 | }); 45 | }; 46 | 47 | componentWillMount() { 48 | Analytics.init(); 49 | } 50 | 51 | componentDidMount() { 52 | this.props.addListener('login-response', this.onLoginResponse); 53 | this.props.addListener('message', this.onMessage); 54 | this.props.addListener('part', this.onPart); 55 | this.props.addListener('join', this.onJoin); 56 | this.props.addListener('start-typing-response', this.onStartTypingResponse); 57 | this.props.addListener('update-typing-messages', this.onUpdateTypingMessages); 58 | 59 | if (this.props.username != null) { 60 | Analytics.onLoginIntention(this.props.username); 61 | 62 | this.onLogin(this.props.username, this.props.people); 63 | } 64 | } 65 | 66 | componentWillUnmount() { 67 | this.props.removeListener('login-response', this.onLoginResponse); 68 | this.props.removeListener('message', this.onMessage); 69 | this.props.removeListener('part', this.onPart); 70 | this.props.removeListener('join', this.onJoin); 71 | this.props.removeListener('start-typing-response', this.onStartTypingResponse); 72 | this.props.removeListener('update-typing-messages', this.onUpdateTypingMessages); 73 | } 74 | 75 | onLoginResponse = ({success, people, error}) => { 76 | if (!success) { 77 | this.refs.loginForm.onError(error); 78 | } 79 | else { 80 | this.refs.loginForm.onSuccess(); 81 | 82 | var peopleList = _.chain(people) 83 | .value(); 84 | 85 | this.onLogin(this.props.username, peopleList); 86 | } 87 | }; 88 | 89 | onMessage = (data) => { 90 | this.refs.history.onMessage(data); 91 | }; 92 | 93 | onPart = (username) => { 94 | this.refs.userList.onPart(username); 95 | this.refs.history.deleteTypingMessage(username); 96 | }; 97 | 98 | onJoin = (username) => { 99 | this.refs.userList.onJoin(username); 100 | }; 101 | 102 | onStartTypingResponse = (messageid) => { 103 | this.setState({currentMessageId: messageid}); 104 | this.refs.messageForm.onStartTypingResponse(messageid); 105 | }; 106 | 107 | onUpdateTypingMessages = (messagesTyping) => { 108 | this.refs.history.onUpdateTypingMessages(messagesTyping); 109 | }; 110 | 111 | onMessageSubmit = (message, messageType) => { 112 | if (this.state.currentMessageId == null) { 113 | //console.log('Don\'t have a message id yet.'); 114 | return false; 115 | } 116 | 117 | const data = { 118 | type: 'channel', 119 | target: this.state.channel, 120 | message_content: message, 121 | messageid: this.state.currentMessageId, 122 | message_type: messageType 123 | }; 124 | this.props.socket.emit('message', data); 125 | 126 | Analytics.onMessageSubmit(message); 127 | 128 | this.setState({currentMessageId: null}); 129 | return true; 130 | }; 131 | 132 | onStartTyping = (message, messageType) => { 133 | var data = { 134 | type: 'channel', 135 | target: this.state.channel, 136 | message_content: message, 137 | message_type: messageType 138 | }; 139 | this.props.socket.emit('start-typing', data); 140 | }; 141 | 142 | onTypingUpdate = (message) => { 143 | if (this.state.currentMessageId == null) { 144 | //console.log('Skipping typing-update'); 145 | return; 146 | } 147 | 148 | var data = { 149 | message_content: message, 150 | messageid: this.state.currentMessageId 151 | }; 152 | this.props.socket.emit('typing-update', data); 153 | }; 154 | 155 | onLoginIntention = (intendedUsername) => { 156 | this.props.updateUsername(intendedUsername); 157 | 158 | Analytics.onLoginIntention(intendedUsername); 159 | this.props.socket.emit('login', intendedUsername); 160 | }; 161 | 162 | render() { 163 | return ( 164 |
    165 |
    166 |

    Ting

    167 | 168 |
    169 |
    170 |
    171 | 172 |
    173 |
    174 | 176 | 181 |
    182 |
    183 | 186 |
    187 | ); 188 | } 189 | } 190 | 191 | module.exports = Ting; 192 | -------------------------------------------------------------------------------- /API/chat/tests/message/test_patch_view.py: -------------------------------------------------------------------------------- 1 | 2 | from chat.tests.message.common import * 3 | 4 | class MessageViewPATCHTests(ChatTests): 5 | client_class = ChatClient 6 | 7 | def patch_and_get_response(self, messageid, message_content, timestamp, typing, message_type): 8 | """ 9 | Patches a message on chat:message and returns the response 10 | """ 11 | qstring = urllib.urlencode({ 12 | 'message_content': message_content, 13 | 'datetime_sent': timestamp, 14 | 'typing': typing, 15 | 'message_type': message_type 16 | }) 17 | return self.privileged_operation( 18 | reverse('chat:message', args=(messageid,)), 19 | qstring, 20 | 'patch' 21 | ) 22 | 23 | def test_patch_message(self): 24 | """ 25 | The view should update the message according to the 26 | data provided and respond with a 204(No Content) code. 27 | """ 28 | timestamp = 10 ** 11 29 | message = create_message( 30 | message_content='Message', 31 | username='vitsalis', 32 | channel=self.channel, 33 | timestamp=timestamp, 34 | message_type='text' 35 | ) 36 | 37 | response = self.patch_and_get_response( 38 | messageid=message.id, 39 | message_content='Message Updated', 40 | timestamp=timestamp + 10, 41 | typing=False, 42 | message_type='text' 43 | ) 44 | 45 | messages = Message.objects.filter(username='vitsalis') 46 | 47 | self.assertTrue(messages.exists()) 48 | self.assertEqual(len(messages), 1) 49 | self.assertEqual(response.status_code, 204) 50 | 51 | self.assertEqual(messages[0].message_content, 'Message Updated') 52 | self.assertEqual(datetime_to_timestamp(messages[0].datetime_start), timestamp) 53 | self.assertEqual(datetime_to_timestamp(messages[0].datetime_sent), timestamp + 10) 54 | self.assertEqual(messages[0].username, 'vitsalis') 55 | self.assertFalse(messages[0].typing) 56 | 57 | def test_patch_message_second_time(self): 58 | """ 59 | The view should not update a message that has been 60 | made persistent. Instead it should respond with a 61 | 400(Bad Request) code. 62 | """ 63 | timestamp = 10 ** 11 64 | message = create_message( 65 | message_content='Message', 66 | username='vitsalis', 67 | channel=self.channel, 68 | timestamp=timestamp, 69 | message_type='text' 70 | ) 71 | 72 | self.patch_and_get_response( 73 | messageid=message.id, 74 | message_content='Message Updated', 75 | timestamp=timestamp + 10, 76 | typing=False, 77 | message_type='text' 78 | ) 79 | 80 | response = self.patch_and_get_response( 81 | messageid=message.id, 82 | message_content='Message Updated Again', 83 | timestamp=timestamp + 100, 84 | typing=False, 85 | message_type='text' 86 | ) 87 | 88 | messages = Message.objects.filter(username='vitsalis') 89 | 90 | self.assertTrue(messages.exists()) 91 | self.assertEqual(messages[0].message_content, 'Message Updated') 92 | 93 | self.assertEqual(response.status_code, 400) 94 | 95 | def test_patch_message_with_datetime_sent_before_datetime_start(self): 96 | """ 97 | When the datetime_sent is before datetime_start the view 98 | should make the datetime_sent equal to the datetime_sent, 99 | save the message and respond with a 204(No Content) code. 100 | """ 101 | timestamp = 10 ** 11 102 | message = create_message( 103 | message_content='Message', 104 | username='vitsalis', 105 | channel=self.channel, 106 | timestamp=timestamp, 107 | message_type='text' 108 | ) 109 | 110 | response = self.patch_and_get_response( 111 | messageid=message.id, 112 | message_content='Message Updated', 113 | timestamp=timestamp - 1, 114 | typing=False, 115 | message_type='text' 116 | ) 117 | 118 | dbmessage = Message.objects.get(pk=message.id) 119 | 120 | self.assertEqual(response.status_code, 204) 121 | 122 | self.assertEqual(dbmessage.message_content, 'Message Updated') 123 | self.assertTrue(hasattr(dbmessage, 'datetime_sent')) 124 | self.assertEqual(dbmessage.datetime_sent, message.datetime_start) 125 | self.assertEqual(dbmessage.datetime_sent, dbmessage.datetime_start) 126 | self.assertEqual(datetime_to_timestamp(dbmessage.datetime_start), timestamp) 127 | self.assertFalse(dbmessage.typing) 128 | 129 | def test_patch_message_without_text(self): 130 | """ 131 | When the text is not specified the view 132 | should not patch the message and respond with a 133 | 400(Bad Request) code. 134 | """ 135 | timestamp = 10 ** 11 136 | message = create_message( 137 | message_content='Message', 138 | username='vitsalis', 139 | channel=self.channel, 140 | timestamp=timestamp, 141 | message_type='text' 142 | ) 143 | 144 | qstring = urllib.urlencode({ 145 | 'datetime_sent': timestamp + 10, 146 | 'typing': False 147 | }) 148 | 149 | response = self.privileged_operation( 150 | reverse('chat:message', args=(message.id,)), 151 | qstring, 152 | 'patch' 153 | ) 154 | 155 | dbmessage = Message.objects.get(pk=message.id) 156 | 157 | self.assertEqual(response.status_code, 400) 158 | 159 | self.assertEqual(dbmessage.message_content, message.message_content) 160 | self.assertIsNone(dbmessage.datetime_sent) 161 | 162 | def test_patch_message_without_datetime_sent(self): 163 | """ 164 | When the datetime_sent is not specified the view 165 | should not patch the message and respond with a 166 | 400(Bad Request) code. 167 | """ 168 | timestamp = 10 ** 11 169 | message = create_message( 170 | message_content='Message', 171 | username='vitsalis', 172 | channel=self.channel, 173 | timestamp=timestamp, 174 | message_type='text' 175 | ) 176 | 177 | qstring = urllib.urlencode({ 178 | 'message_content': 'Message Updated', 179 | 'typing': False 180 | }) 181 | 182 | response = self.privileged_operation( 183 | reverse('chat:message', args=(message.id,)), 184 | qstring, 185 | 'patch' 186 | ) 187 | 188 | dbmessage = Message.objects.get(pk=message.id) 189 | 190 | self.assertEqual(response.status_code, 400) 191 | 192 | self.assertEqual(dbmessage.message_content, message.message_content) 193 | self.assertIsNone(dbmessage.datetime_sent) 194 | -------------------------------------------------------------------------------- /API/chat/tests/message/test_post_view.py: -------------------------------------------------------------------------------- 1 | from chat.tests.message.common import * 2 | 3 | from django.utils.dateformat import format 4 | 5 | class MessageViewPOSTTests(ChatTests): 6 | def post_and_get_response(self, message_content, timestamp, username, typing, message_type): 7 | """ 8 | Posts a message on chat:message and returns the response 9 | """ 10 | return self.privileged_operation( 11 | reverse('chat:message', args=('channel', self.channel.name,)), 12 | {'message_content': message_content, 'username': username, 'datetime_start': timestamp, 'typing': typing, 'message_type': message_type}, 13 | 'post' 14 | ) 15 | 16 | def test_post_valid_message(self): 17 | """ 18 | When a valid message is sent, the view should 19 | save the message in the database and return 20 | the id of the message. 21 | """ 22 | timestamp = 10 ** 11 23 | username = 'vitsalisa' 24 | message_content = 'Message' 25 | message_type = 'text' 26 | 27 | response = self.post_and_get_response( 28 | message_content=message_content, 29 | timestamp=timestamp, 30 | username=username, 31 | typing=True, 32 | message_type=message_type 33 | ) 34 | 35 | messages = Message.objects.filter(username=username) 36 | 37 | self.assertTrue(messages.exists()) 38 | self.assertEquals(len(messages), 1) 39 | self.assertEqual(response.status_code, 200) 40 | 41 | message = Message.objects.get(username=username); 42 | 43 | self.assertEqual(int(response.content), message.id); 44 | self.assertEqual(message.username, username); 45 | self.assertTrue(message.typing) 46 | self.assertEqual(message.message_content, message_content) 47 | self.assertEqual(datetime_to_timestamp(message.datetime_start), timestamp) 48 | 49 | def test_post_message_without_datetime_start(self): 50 | """ 51 | When a message is sent without a datetime_start the view 52 | should produce an appropriate error and a 400(Bad Request) 53 | status code. The message should not be saved. 54 | """ 55 | post_dict = {'message_content': 'Message', 'username': 'vitsalis', 'typing': True, 'message_type': 'text'} 56 | 57 | response = self.privileged_operation( 58 | reverse('chat:message', args=('channel', self.channel.name,)), 59 | post_dict, 60 | 'post' 61 | ) 62 | 63 | self.assertFalse(Message.objects.filter(username='vitsalis').exists()) 64 | self.assertEqual(response.status_code, 400) 65 | 66 | def test_post_message_without_username(self): 67 | """ 68 | When a message is sent without a username the view 69 | should produce an appropriate error and a 400(Bad Request) 70 | status code. The message should not be saved. 71 | """ 72 | timestamp = 10 ** 11 73 | post_dict = {'message_content': 'Message', 'datetime_start': timestamp, 'typing': True, 'message_type': 'text'} 74 | 75 | response = self.privileged_operation( 76 | reverse('chat:message', args=('channel', self.channel.name,)), 77 | post_dict, 78 | 'post' 79 | ) 80 | 81 | datetime_start_field = timestamp_to_datetime(timestamp) 82 | self.assertFalse(Message.objects.filter(datetime_start=datetime_start_field).exists()) 83 | self.assertEqual(response.status_code, 400) 84 | 85 | def test_post_message_with_invalid_channel_name(self): 86 | """ 87 | When a message is sent with an invalid channel name 88 | the view should produce an appropriate error and a 89 | 404(Not Found) status code. The message should not be saved. 90 | """ 91 | timestamp = 10 ** 11 92 | 93 | response = self.privileged_operation( 94 | reverse('chat:message', args=('channel', 'invalid_channel',)), 95 | {'message_content': 'Message', 'username': 'vitsalis', 'datetime_start': timestamp, 'typing': True, 'message_type': 'text'}, 96 | 'post' 97 | ) 98 | 99 | self.assertFalse(Message.objects.filter(username='vitsalis').exists()) 100 | self.assertEqual(response.status_code, 404) 101 | 102 | def test_post_message_without_text(self): 103 | """ 104 | When a message is sent without a channel_id the view 105 | should produce an appropriate error and a 400(Bad Request) 106 | status code. The message should not be saved. 107 | """ 108 | timestamp = 10 ** 11 109 | post_dict = {'username': 'vitsalis', 'datetime_start': timestamp, 'typing': True, 'message_type': 'text'} 110 | 111 | response = self.privileged_operation( 112 | reverse('chat:message', args=('channel', self.channel.name,)), 113 | post_dict, 114 | 'post' 115 | ) 116 | 117 | self.assertFalse(Message.objects.filter(username='vitsalis').exists()) 118 | self.assertEqual(response.status_code, 400) 119 | 120 | def test_post_message_with_invalid_datetime_start(self): 121 | """ 122 | When a message is sent with an invalid datetime the view 123 | should produce an appropriate error and a 400(Bad Request) 124 | status code. The message should not be saved. 125 | """ 126 | response = self.post_and_get_response( 127 | message_content='Message', 128 | timestamp='wtf', 129 | username='vitsalis', 130 | typing=True, 131 | message_type='text' 132 | ) 133 | 134 | self.assertFalse(Message.objects.filter(username='vitsalis').exists()) 135 | self.assertEqual(response.status_code, 400) 136 | 137 | def test_post_message_with_future_datetime_start(self): 138 | """ 139 | When a message is sent with a future datetime the view 140 | should change the datetime to the current one and save the message. 141 | """ 142 | timestamp = int(format(datetime.datetime.utcnow() + datetime.timedelta(days=1), 'U')) * 1000 143 | response = self.post_and_get_response( 144 | message_content='Message', 145 | timestamp=timestamp, 146 | username='vitsalis', 147 | typing=True, 148 | message_type='text' 149 | ) 150 | 151 | messages = Message.objects.filter(username='vitsalis') 152 | self.assertTrue(messages.exists()) 153 | self.assertEqual(len(messages), 1) 154 | 155 | self.assertTrue(datetime_to_timestamp(messages[0].datetime_start) < timestamp) 156 | self.assertEqual(response.status_code, 200) 157 | self.assertEqual(int(response.content), messages[0].id) 158 | 159 | def test_post_message_with_typing_false(self): 160 | """ 161 | When typing is False the view should save the message 162 | and make its datetime_sent equal to datetime_start. 163 | """ 164 | timestamp = 10 ** 11 165 | 166 | response = self.post_and_get_response( 167 | message_content='Message', 168 | timestamp=timestamp, 169 | username='vitsalis', 170 | typing=False, 171 | message_type='text' 172 | ) 173 | 174 | messages = Message.objects.filter(username='vitsalis') 175 | self.assertTrue(messages.exists()) 176 | self.assertEqual(len(messages), 1) 177 | 178 | self.assertEqual(messages[0].datetime_sent, messages[0].datetime_start) 179 | -------------------------------------------------------------------------------- /API/chat/tests/message/test_get_view.py: -------------------------------------------------------------------------------- 1 | from chat.tests.message.common import * 2 | 3 | from django_dynamic_fixture import G 4 | 5 | class MessageViewGETTests(ChatTests): 6 | def test_request_messages(self): 7 | """ 8 | When a valid request is sent the view should return 9 | a JSON object containing messages. Each message should be 10 | in the form {message_content: ...,username: ..., datetime: ...}. 11 | The messages should be in chronological order(more recent first). 12 | The number of objects is specified by the lim argument. 13 | """ 14 | lim = 2 15 | timestamp = 10 ** 11 16 | 17 | message1 = Message.objects.create( 18 | message_content='Message1', 19 | datetime_start=timestamp_to_datetime(timestamp), 20 | datetime_sent=timestamp_to_datetime(timestamp + 10), 21 | username='vitsalis', 22 | typing=True, 23 | channel=self.channel, 24 | message_type='text' 25 | ) 26 | 27 | message2 = Message.objects.create( 28 | message_content='Message2', 29 | datetime_start=timestamp_to_datetime(timestamp + 60 * 60), 30 | datetime_sent=timestamp_to_datetime(timestamp + 60 * 60 + 10), 31 | username='pkakelas', 32 | typing=True, 33 | channel=self.channel, 34 | message_type='text' 35 | ) 36 | 37 | response = self.client.get( 38 | reverse('chat:message', args=('channel', self.channel.name,)), 39 | {'lim': lim} 40 | ) 41 | messages = json.loads(response.content) 42 | 43 | self.assertEqual(response.status_code, 200) 44 | self.assertEqual(len(messages), 2) 45 | 46 | # The order is reverse chronological 47 | self.assertEqual(messages[0]['message_content'], message2.message_content) 48 | self.assertEqual(messages[0]['username'], message2.username) 49 | self.assertEqual(messages[0]['datetime_start'], datetime_to_timestamp(message2.datetime_start)) 50 | self.assertTrue(messages[0]['typing']) 51 | self.assertEqual(messages[0]['id'], message2.id) 52 | self.assertEqual(messages[0]['datetime_sent'], datetime_to_timestamp(message2.datetime_sent)) 53 | 54 | self.assertEqual(messages[1]['message_content'], message1.message_content) 55 | self.assertEqual(messages[1]['username'], message1.username) 56 | self.assertEqual(messages[1]['datetime_start'], datetime_to_timestamp(message1.datetime_start)) 57 | self.assertTrue(messages[1]['typing']) 58 | self.assertEqual(messages[1]['id'], message1.id) 59 | self.assertEqual(messages[1]['datetime_sent'], datetime_to_timestamp(message1.datetime_sent)) 60 | 61 | def test_request_messages_with_bigger_limit_than_messages(self): 62 | """ 63 | When the lim is bigger than the number of the messages 64 | on the database for the channel, the server should return 65 | all the messages for the channel. 66 | """ 67 | lim = 100 68 | timestamp = 10 ** 11 69 | 70 | create_message( 71 | message_content='Message1', 72 | timestamp=timestamp, 73 | username='vitsalis', 74 | channel=self.channel, 75 | message_type='text' 76 | ) 77 | 78 | create_message( 79 | message_content='Message2', 80 | timestamp=timestamp + 60 * 60, 81 | username='pkakelas', 82 | channel=self.channel, 83 | message_type='text' 84 | ) 85 | 86 | messages = json.loads(self.client.get( 87 | reverse('chat:message', args=('channel', self.channel.name,)), 88 | {'lim': lim} 89 | ).content) 90 | 91 | self.assertEqual(len(messages), 2) 92 | 93 | def test_request_messages_with_smaller_limit_than_messages(self): 94 | """ 95 | When the lim is smaller than the number of the messages 96 | on the database for the channel, the server should return 97 | no more than messages. 98 | """ 99 | 100 | lim = 2 101 | timestamp = 10 ** 11 102 | 103 | for i in range(100): 104 | create_message( 105 | message_content='Message' + str(i), 106 | timestamp=timestamp + i, 107 | username='vitsalis', 108 | channel=self.channel, 109 | message_type='text' 110 | ) 111 | 112 | messages = json.loads(self.client.get( 113 | reverse('chat:message', args=('channel', self.channel.name,)), 114 | {'lim': lim} 115 | ).content) 116 | 117 | self.assertEqual(len(messages), 2) 118 | 119 | self.assertEqual(messages[0]['message_content'], 'Message99') 120 | self.assertEqual(messages[1]['message_content'], 'Message98') 121 | 122 | def test_request_messages_without_lim(self): 123 | """ 124 | When the lim is not specified the view should return 125 | 100 messages(or less if there are less than 100 messages). 126 | """ 127 | timestamp = 10 ** 11 128 | 129 | for i in range(200): 130 | create_message( 131 | message_content='Message' + str(i), 132 | timestamp=timestamp + i, 133 | username='vitsalis', 134 | channel=self.channel, 135 | message_type='text' 136 | ) 137 | 138 | messages = json.loads(self.client.get( 139 | reverse('chat:message', args=('channel', self.channel.name,)), 140 | ).content) 141 | 142 | self.assertEqual(len(messages), 100) 143 | 144 | def test_request_messages_from_one_channel(self): 145 | """ 146 | The view should return the messages from the 147 | channel specified. 148 | """ 149 | channel1 = G(Channel, name='Channel1') 150 | channel2 = G(Channel, name='Channel2') 151 | timestamp = 10 ** 11 152 | 153 | message1 = create_message( 154 | message_content='Message1', 155 | timestamp=timestamp, 156 | username='vitsalis', 157 | channel=channel1, 158 | message_type='text' 159 | ) 160 | 161 | create_message( 162 | message_content='Message2', 163 | timestamp=timestamp, 164 | username='vitsalis', 165 | channel=channel2, 166 | message_type='text' 167 | ) 168 | 169 | messages = json.loads(self.client.get( 170 | reverse('chat:message', args=('channel', channel1.name,)), 171 | ).content) 172 | 173 | self.assertEqual(len(messages), 1) 174 | 175 | self.assertEqual(messages[0]['message_content'], message1.message_content) 176 | 177 | def test_request_messages_with_invalid_channel_name(self): 178 | """ 179 | When the channel with the name 180 | does not exist, a 404(Not Found) response code 181 | should be returned from the view. 182 | """ 183 | timestamp = 10 ** 11 184 | 185 | create_message( 186 | message_content='Message1', 187 | timestamp=timestamp, 188 | username='vitsalis', 189 | channel=self.channel, 190 | message_type='text' 191 | ) 192 | 193 | response = self.client.get( 194 | reverse('chat:message', args=('channel', 'invalid_name',)), 195 | ) 196 | 197 | self.assertEqual(response.status_code, 404) 198 | -------------------------------------------------------------------------------- /realtime/server.js: -------------------------------------------------------------------------------- 1 | var io = require('socket.io'); 2 | var req = require('request'); 3 | var fs = require('fs'); 4 | var winston = require('winston'); 5 | var _ = require('lodash'); 6 | 7 | winston.add(winston.transports.File, { filename: 'server.log' }); 8 | winston.level = 'debug'; 9 | 10 | // Obtain information like 'hostname' and 'port' from common.json 11 | var config = JSON.parse(fs.readFileSync('../config/common.json', 'utf8')); 12 | 13 | // if local.json exists we are running ting locally, so we overwrite info of 'config' variable 14 | if (fs.existsSync('../config/local.json')) { 15 | config = JSON.parse(fs.readFileSync('../config/local.json', 'utf8')); 16 | } 17 | 18 | URL = 'http://' + config.node.hostname; 19 | PORT = config.node.port; 20 | PASS = config.password; 21 | var socket = io.listen(PORT); 22 | 23 | var people = {}; 24 | var usernames = {}; 25 | var messages_typing = {}; 26 | 27 | function getOptions(form, path, method) { 28 | var headers = { 29 | 'User-Agent': 'node-ting/0.1.0', 30 | 'Content-Type': 'application/x-www-form-urlencoded', 31 | 'Authorization': PASS 32 | } 33 | 34 | return { 35 | url: URL + '/api/messages/' + path + '/', 36 | method: method, 37 | headers: headers, 38 | form: form 39 | } 40 | } 41 | 42 | function sendLoginErrorResponse(username, client, error) { 43 | winston.info('Username [' + username + '] of client with id ' + client.id + ' had error ' + error + '.'); 44 | client.emit('login-response', { 45 | success: false, 46 | error: error 47 | }); 48 | } 49 | 50 | function logUsersCount() { 51 | winston.info('Currently ' + Object.keys(usernames).length + ' users are logged in the server.'); 52 | } 53 | 54 | winston.info('Ting real-time server v1 listening on port ' + config.node.port + '.'); 55 | winston.debug('Debug logging is enabled. Disable it if you see too many logs.'); 56 | winston.debug('Using persistence API back-end at ' + URL); 57 | 58 | socket.on('connection', function (client) { 59 | winston.info('A user with client id "' + client.id + '" connected.'); 60 | client.on('login', function(username) { 61 | var rex = /^[ά-ώα-ωa-z0-9]+$/i; 62 | var resp = { 63 | success: true 64 | }; 65 | if (username == '') { 66 | sendLoginErrorResponse(username, client, 'empty'); 67 | return; 68 | } 69 | if (username.length > 20) { 70 | sendLoginErrorResponse(username, client, 'length'); 71 | return; 72 | } 73 | if (!rex.test(username)) { 74 | sendLoginErrorResponse(username, client, 'chars'); 75 | return; 76 | } 77 | if (usernames[username]) { 78 | sendLoginErrorResponse(username, client, 'taken'); 79 | return; 80 | } 81 | people[client.id] = username; 82 | resp.people = _.values(people); 83 | usernames[username] = true; // true means that 'username' exists 84 | winston.info('[' + username + '] login'); 85 | logUsersCount(); 86 | client.emit('login-response', resp); 87 | socket.sockets.emit('join', username); 88 | }); 89 | 90 | client.on('message', function(data) { 91 | var message_content = data.message_content; 92 | var messageType = data.message_type; 93 | data.username = people[client.id]; 94 | socket.sockets.emit('message', data); 95 | winston.info('[' + data.username + '] message: ' + message_content); 96 | delete messages_typing[data.messageid]; 97 | 98 | var path = data.messageid; 99 | 100 | var options = getOptions({ 101 | id: data.messageid, 102 | message_content: message_content, 103 | datetime_sent: Date.now(), 104 | typing: false, 105 | message_type: messageType 106 | }, path, 'PATCH'); 107 | 108 | req(options, function(error, response, body) { 109 | if (error) { 110 | winston.warn('Error communicating with Django with PATCH request: ' + error); 111 | } 112 | }); 113 | }); 114 | 115 | client.on('start-typing', function(data) { 116 | winston.debug('[' + people[client.id] + '] start-typing'); 117 | var form = { 118 | username: people[client.id], 119 | message_content: data.message_content, 120 | target: data.target, 121 | datetime_start: Date.now(), 122 | typing: true, 123 | message_type: data.message_type 124 | }; 125 | 126 | var path = data.type + '/' + data.target; 127 | 128 | var options = getOptions(form, path, 'POST'); 129 | 130 | req(options, function(error, response, body) { 131 | if (error) { 132 | winston.error('Message from user: ' + 133 | data.username + 134 | " couldn't be sent to Django (" + 135 | options.url + 136 | "). " + 137 | error); 138 | } 139 | else { 140 | messageid = body; 141 | 142 | messages_typing[messageid] = form; 143 | socket.sockets.emit('update-typing-messages', messages_typing); 144 | client.emit('start-typing-response', messageid); 145 | } 146 | }); 147 | }); 148 | 149 | function deletePersistentMessage(id) { 150 | var path = id; 151 | var options = getOptions({}, path, 'DELETE'); 152 | 153 | req(options, function(error, response, body) { 154 | if (error) { 155 | winston.warn('Error communicating with Django with DELETE request: ' + error); 156 | } 157 | }); 158 | } 159 | 160 | client.on('typing-update', function(data) { 161 | winston.debug('[' + people[client.id] + '] typing-update'); 162 | 163 | // check for possible errors, while the message is being processed 164 | if (!messages_typing[data.messageid]) { 165 | winston.warn('There is no message with id: ' + data.messageid); 166 | return; 167 | } 168 | 169 | if (messages_typing[data.messageid].username != people[client.id]) { 170 | winston.warn('messageid ' + data.messageid + ' does not belong to user ' + people[client.id]); 171 | return; 172 | } 173 | 174 | messages_typing[data.messageid].message_content = data.message_content; 175 | socket.sockets.emit('update-typing-messages', messages_typing); 176 | 177 | if (data.message_content.trim().length == 0) { // if message is deleted, then we delete its info 178 | delete messages_typing[data.messageid]; 179 | deletePersistentMessage(data.messageid); 180 | } 181 | }) 182 | 183 | client.on('disconnect', function() { 184 | var username = people[client.id]; 185 | 186 | var messagesTypingNew = {}; 187 | 188 | // if the user is writing a message, and he disconnects, we delete the message 189 | for (var messageid in messages_typing) { 190 | var message = messages_typing[messageid]; 191 | if (messages_typing[messageid].username == username) { 192 | deletePersistentMessage(messageid); 193 | } 194 | else { 195 | messagesTypingNew[messageid] = message; // object keeps only the messages whose user is still connected 196 | } 197 | } 198 | 199 | messages_typing = messagesTypingNew; 200 | 201 | delete people[client.id]; 202 | delete usernames[username]; 203 | socket.sockets.emit('part', username); 204 | winston.info('[' + username + '] disconnect'); 205 | logUsersCount(); 206 | }); 207 | }); 208 | -------------------------------------------------------------------------------- /etc/mockups/ting-channels.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Ting 6 | 7 | 8 | 9 | 10 |
    11 | 16 |

    Ting

    17 |
    18 |
    19 |
    20 |
      21 |
    • ting
    • 22 |
    • ting-dev
    • 23 |
    • vitsalis vitsalis
    • 24 |
    • pkakelas pkakelas
    • 25 |
    • algorithms
    • 26 |
    • gtklocker
    • 27 |
    28 |
    29 |
    30 |
    31 |
    32 |
    • pkakelas pkakelas
      lala
    • Stracci Stracci
      Brucia la luna n'cielu E ju bruciu d'amuri
    • Stracci Stracci
      Brucia la luna n'cielu E ju bruciu d'amuri
    • vitsalis vitsalis
      wat
    • Stracci Stracci
      Brucia la luna n'cielu E ju bruciu d'amuri
    • dionyziz dionyziz
      yo
    • dionyziz dionyziz
      χμ γιατί δε δείχνει τα δικά μου μηνύματα με άλλο χρώμα;
    • 33 | 34 |
    • dionyziz dionyziz
      lol
    • dionyziz dionyziz
      ανέβασα το spec με privates + channels
    • dionyziz dionyziz
      ρίξτε μια ματιά
    • dionyziz dionyziz
    • vitsalis vitsalis
      με τι ματι;
    • dionyziz dionyziz
      χαχαχ
    • dionyziz dionyziz
      :P
    • dionyziz dionyziz
      πω πολύ πιο ωραία τα μηνύματα τώρα που έγιναν έτσι γκρι
    • dionyziz dionyziz
      θα τα φτιάξω να γίνονται και μπλε όταν γράφεις ο ίδιος
    • dionyziz dionyziz
      επιτέλους στοιχίθηκαν τα ονόματα στα αριστερά με το textarea
    • dionyziz dionyziz
      μου έσπαγε τα νεύρα
    • dionyziz dionyziz
      αν και νομίζω φαίνεται πιο ωραίο με λίγο περισσότερο padding ανάμεσα σε δύο διαδοχικά μηνύματα
    • vitsalis vitsalis
      wat
    35 |
    36 |
    37 |
    38 |
    39 | 40 |
    41 |
    42 |
    43 |
    44 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /etc/mockups/login-mockups/css/bootstrap-theme.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.1.0 (http://getbootstrap.com) 3 | * Copyright 2011-2014 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | 7 | .btn-default,.btn-primary,.btn-success,.btn-info,.btn-warning,.btn-danger{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-default:active,.btn-primary:active,.btn-success:active,.btn-info:active,.btn-warning:active,.btn-danger:active,.btn-default.active,.btn-primary.active,.btn-success.active,.btn-info.active,.btn-warning.active,.btn-danger.active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn:active,.btn.active{background-image:none}.btn-default{background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;text-shadow:0 1px 0 #fff;border-color:#ccc}.btn-default:hover,.btn-default:focus{background-color:#e0e0e0;background-position:0 -15px}.btn-default:active,.btn-default.active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-primary{background-image:-webkit-linear-gradient(top,#428bca 0,#2d6ca2 100%);background-image:linear-gradient(to bottom,#428bca 0,#2d6ca2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff2d6ca2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#2b669a}.btn-primary:hover,.btn-primary:focus{background-color:#2d6ca2;background-position:0 -15px}.btn-primary:active,.btn-primary.active{background-color:#2d6ca2;border-color:#2b669a}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:hover,.btn-success:focus{background-color:#419641;background-position:0 -15px}.btn-success:active,.btn-success.active{background-color:#419641;border-color:#3e8f3e}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:hover,.btn-info:focus{background-color:#2aabd2;background-position:0 -15px}.btn-info:active,.btn-info.active{background-color:#2aabd2;border-color:#28a4c9}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:hover,.btn-warning:focus{background-color:#eb9316;background-position:0 -15px}.btn-warning:active,.btn-warning.active{background-color:#eb9316;border-color:#e38d13}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:hover,.btn-danger:focus{background-color:#c12e2a;background-position:0 -15px}.btn-danger:active,.btn-danger.active{background-color:#c12e2a;border-color:#b92c28}.thumbnail,.img-thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-color:#e8e8e8}.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus{background-image:-webkit-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:linear-gradient(to bottom,#428bca 0,#357ebd 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0);background-color:#357ebd}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f3f3f3 100%);background-image:linear-gradient(to bottom,#ebebeb 0,#f3f3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff3f3f3', GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.navbar-inverse .navbar-nav>.active>a{background-image:-webkit-linear-gradient(top,#222 0,#282828 100%);background-image:linear-gradient(to bottom,#222 0,#282828 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff222222', endColorstr='#ff282828', GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-static-top,.navbar-fixed-top,.navbar-fixed-bottom{border-radius:0}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0)}.progress-bar{background-image:-webkit-linear-gradient(top,#428bca 0,#3071a9 100%);background-image:linear-gradient(to bottom,#428bca 0,#3071a9 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3071a9', GradientType=0)}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0)}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0)}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0)}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:hover,.list-group-item.active:focus{text-shadow:0 -1px 0 #3071a9;background-image:-webkit-linear-gradient(top,#428bca 0,#3278b3 100%);background-image:linear-gradient(to bottom,#428bca 0,#3278b3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3278b3', GradientType=0);border-color:#3278b3}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0)}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:linear-gradient(to bottom,#428bca 0,#357ebd 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0)}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0)}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0)}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0)}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0)}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} -------------------------------------------------------------------------------- /etc/mockups/topbar-mockup/css/bootstrap-theme.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.1.0 (http://getbootstrap.com) 3 | * Copyright 2011-2014 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | 7 | .btn-default,.btn-primary,.btn-success,.btn-info,.btn-warning,.btn-danger{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-default:active,.btn-primary:active,.btn-success:active,.btn-info:active,.btn-warning:active,.btn-danger:active,.btn-default.active,.btn-primary.active,.btn-success.active,.btn-info.active,.btn-warning.active,.btn-danger.active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn:active,.btn.active{background-image:none}.btn-default{background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;text-shadow:0 1px 0 #fff;border-color:#ccc}.btn-default:hover,.btn-default:focus{background-color:#e0e0e0;background-position:0 -15px}.btn-default:active,.btn-default.active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-primary{background-image:-webkit-linear-gradient(top,#428bca 0,#2d6ca2 100%);background-image:linear-gradient(to bottom,#428bca 0,#2d6ca2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff2d6ca2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#2b669a}.btn-primary:hover,.btn-primary:focus{background-color:#2d6ca2;background-position:0 -15px}.btn-primary:active,.btn-primary.active{background-color:#2d6ca2;border-color:#2b669a}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:hover,.btn-success:focus{background-color:#419641;background-position:0 -15px}.btn-success:active,.btn-success.active{background-color:#419641;border-color:#3e8f3e}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:hover,.btn-info:focus{background-color:#2aabd2;background-position:0 -15px}.btn-info:active,.btn-info.active{background-color:#2aabd2;border-color:#28a4c9}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:hover,.btn-warning:focus{background-color:#eb9316;background-position:0 -15px}.btn-warning:active,.btn-warning.active{background-color:#eb9316;border-color:#e38d13}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:hover,.btn-danger:focus{background-color:#c12e2a;background-position:0 -15px}.btn-danger:active,.btn-danger.active{background-color:#c12e2a;border-color:#b92c28}.thumbnail,.img-thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-color:#e8e8e8}.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus{background-image:-webkit-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:linear-gradient(to bottom,#428bca 0,#357ebd 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0);background-color:#357ebd}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f3f3f3 100%);background-image:linear-gradient(to bottom,#ebebeb 0,#f3f3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff3f3f3', GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.navbar-inverse .navbar-nav>.active>a{background-image:-webkit-linear-gradient(top,#222 0,#282828 100%);background-image:linear-gradient(to bottom,#222 0,#282828 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff222222', endColorstr='#ff282828', GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-static-top,.navbar-fixed-top,.navbar-fixed-bottom{border-radius:0}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0)}.progress-bar{background-image:-webkit-linear-gradient(top,#428bca 0,#3071a9 100%);background-image:linear-gradient(to bottom,#428bca 0,#3071a9 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3071a9', GradientType=0)}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0)}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0)}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0)}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:hover,.list-group-item.active:focus{text-shadow:0 -1px 0 #3071a9;background-image:-webkit-linear-gradient(top,#428bca 0,#3278b3 100%);background-image:linear-gradient(to bottom,#428bca 0,#3278b3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3278b3', GradientType=0);border-color:#3278b3}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0)}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:linear-gradient(to bottom,#428bca 0,#357ebd 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0)}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0)}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0)}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0)}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0)}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} -------------------------------------------------------------------------------- /etc/mockups/settings-mockup/css/bootstrap-theme.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.1.0 (http://getbootstrap.com) 3 | * Copyright 2011-2014 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | 7 | .btn-default,.btn-primary,.btn-success,.btn-info,.btn-warning,.btn-danger{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-default:active,.btn-primary:active,.btn-success:active,.btn-info:active,.btn-warning:active,.btn-danger:active,.btn-default.active,.btn-primary.active,.btn-success.active,.btn-info.active,.btn-warning.active,.btn-danger.active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn:active,.btn.active{background-image:none}.btn-default{background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;text-shadow:0 1px 0 #fff;border-color:#ccc}.btn-default:hover,.btn-default:focus{background-color:#e0e0e0;background-position:0 -15px}.btn-default:active,.btn-default.active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-primary{background-image:-webkit-linear-gradient(top,#428bca 0,#2d6ca2 100%);background-image:linear-gradient(to bottom,#428bca 0,#2d6ca2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff2d6ca2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#2b669a}.btn-primary:hover,.btn-primary:focus{background-color:#2d6ca2;background-position:0 -15px}.btn-primary:active,.btn-primary.active{background-color:#2d6ca2;border-color:#2b669a}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:hover,.btn-success:focus{background-color:#419641;background-position:0 -15px}.btn-success:active,.btn-success.active{background-color:#419641;border-color:#3e8f3e}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:hover,.btn-info:focus{background-color:#2aabd2;background-position:0 -15px}.btn-info:active,.btn-info.active{background-color:#2aabd2;border-color:#28a4c9}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:hover,.btn-warning:focus{background-color:#eb9316;background-position:0 -15px}.btn-warning:active,.btn-warning.active{background-color:#eb9316;border-color:#e38d13}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:hover,.btn-danger:focus{background-color:#c12e2a;background-position:0 -15px}.btn-danger:active,.btn-danger.active{background-color:#c12e2a;border-color:#b92c28}.thumbnail,.img-thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-color:#e8e8e8}.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus{background-image:-webkit-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:linear-gradient(to bottom,#428bca 0,#357ebd 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0);background-color:#357ebd}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f3f3f3 100%);background-image:linear-gradient(to bottom,#ebebeb 0,#f3f3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff3f3f3', GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.navbar-inverse .navbar-nav>.active>a{background-image:-webkit-linear-gradient(top,#222 0,#282828 100%);background-image:linear-gradient(to bottom,#222 0,#282828 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff222222', endColorstr='#ff282828', GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-static-top,.navbar-fixed-top,.navbar-fixed-bottom{border-radius:0}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0)}.progress-bar{background-image:-webkit-linear-gradient(top,#428bca 0,#3071a9 100%);background-image:linear-gradient(to bottom,#428bca 0,#3071a9 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3071a9', GradientType=0)}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0)}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0)}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0)}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:hover,.list-group-item.active:focus{text-shadow:0 -1px 0 #3071a9;background-image:-webkit-linear-gradient(top,#428bca 0,#3278b3 100%);background-image:linear-gradient(to bottom,#428bca 0,#3278b3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff3278b3', GradientType=0);border-color:#3278b3}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0)}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:linear-gradient(to bottom,#428bca 0,#357ebd 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca', endColorstr='#ff357ebd', GradientType=0)}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0)}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0)}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0)}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0)}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} --------------------------------------------------------------------------------